From 68ed18d3f417abdd626982fba6325e1a887eb2f0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 5 Mar 2024 17:39:37 -0700 Subject: [PATCH] Reviewed buttons (#10271) * mark items as reviewed when they are opened * Update api to use json and add button to mark all as reviewed * fix api so last24 hours has its own review summary * fix sidebar spacing * formatting * Bug fixes * Make motion activity respect filters --- frigate/api/review.py | 149 +++++++++++++++--- .../components/filter/ReviewActionGroup.tsx | 2 +- web/src/components/navigation/Sidebar.tsx | 31 ++-- .../components/player/DynamicVideoPlayer.tsx | 15 +- web/src/components/ui/button.tsx | 1 + web/src/pages/Events.tsx | 37 +++-- web/src/types/review.ts | 6 +- web/src/views/events/EventView.tsx | 53 +++++-- 8 files changed, 219 insertions(+), 75 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 2fdc1ef0e..9f84fb9a1 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -76,11 +76,112 @@ def review(): def review_summary(): tz_name = request.args.get("timezone", default="utc", type=str) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) + day_ago = (datetime.now() - timedelta(hours=24)).timestamp() month_ago = (datetime.now() - timedelta(days=30)).timestamp() cameras = request.args.get("cameras", "all") labels = request.args.get("labels", "all") + clauses = [(ReviewSegment.start_time > day_ago)] + + if cameras != "all": + camera_list = cameras.split(",") + clauses.append((ReviewSegment.camera << camera_list)) + + if labels != "all": + # use matching so segments with multiple labels + # still match on a search where any label matches + label_clauses = [] + filtered_labels = labels.split(",") + + for label in filtered_labels: + label_clauses.append( + (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') + ) + + label_clause = reduce(operator.or_, label_clauses) + clauses.append((label_clause)) + + last_24 = ( + ReviewSegment.select( + 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(reduce(operator.and_, clauses)) + .dicts() + .get() + ) + clauses = [(ReviewSegment.start_time > month_ago)] if cameras != "all": @@ -101,7 +202,7 @@ def review_summary(): label_clause = reduce(operator.or_, label_clauses) clauses.append((label_clause)) - groups = ( + last_month = ( ReviewSegment.select( fn.strftime( "%Y-%m-%d", @@ -192,29 +293,20 @@ def review_summary(): .order_by(ReviewSegment.start_time.desc()) ) - return jsonify([e for e in groups.dicts().iterator()]) + data = { + "last24Hours": last_24, + } + + for e in last_month.dicts().iterator(): + data[e["day"]] = e + + return jsonify(data) -@ReviewBp.route("/review//viewed", methods=("POST",)) -def set_reviewed(id): - try: - review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id) - except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Review " + id + " not found"}), 404 - ) - - review.has_been_reviewed = True - review.save() - - return make_response( - jsonify({"success": True, "message": "Reviewed " + id + " viewed"}), 200 - ) - - -@ReviewBp.route("/reviews//viewed", methods=("POST",)) -def set_multiple_reviewed(ids: str): - list_of_ids = ids.split(",") +@ReviewBp.route("/reviews/viewed", methods=("POST",)) +def set_multiple_reviewed(): + json: dict[str, any] = request.get_json(silent=True) or {} + list_of_ids = json.get("ids", "") if not list_of_ids or len(list_of_ids) == 0: return make_response( @@ -264,13 +356,17 @@ def delete_reviews(ids: str): @ReviewBp.route("/review/activity") def review_activity(): """Get motion and audio activity.""" + cameras = request.args.get("cameras", "all") before = request.args.get("before", type=float, default=datetime.now().timestamp()) after = request.args.get( "after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp() ) - # get scale in seconds - scale = request.args.get("scale", type=int, default=30) + clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] + + if cameras != "all": + camera_list = cameras.split(",") + clauses.append((Recordings.camera << camera_list)) all_recordings: list[Recordings] = ( Recordings.select( @@ -280,7 +376,7 @@ def review_activity(): Recordings.motion, Recordings.dBFS, ) - .where((Recordings.start_time > after) & (Recordings.end_time < before)) + .where(reduce(operator.and_, clauses)) .order_by(Recordings.start_time.asc()) .iterator() ) @@ -298,6 +394,9 @@ def review_activity(): } ) + # get scale in seconds + scale = request.args.get("scale", type=int, default=30) + # resample data using pandas to get activity on scaled basis df = pd.DataFrame(data, columns=["start_time", "motion", "audio"]) diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index 1453792f8..5e1a93185 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -22,7 +22,7 @@ export default function ReviewActionGroup({ const onMarkAsReviewed = useCallback(async () => { const idList = selectedReviews.join(","); - await axios.post(`reviews/${idList}/viewed`); + await axios.post(`reviews/viewed`, { ids: idList }); setSelectedReviews([]); pullLatestData(); }, [selectedReviews, setSelectedReviews, pullLatestData]); diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx index 1956a8562..024c9113c 100644 --- a/web/src/components/navigation/Sidebar.tsx +++ b/web/src/components/navigation/Sidebar.tsx @@ -13,20 +13,23 @@ function Sidebar() {
- {navbarLinks.map((item) => ( -
- - {item.id == 1 && item.url == location.pathname && ( - - )} -
- ))} + {navbarLinks.map((item) => { + const showCameraGroups = + item.id == 1 && item.url == location.pathname; + + return ( +
+ + {showCameraGroups && } +
+ ); + })}
diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 37a4d509a..590c98481 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = { timeRange: { start: number; end: number }; cameraPreviews: Preview[]; previewOnly?: boolean; - onControllerReady?: (controller: DynamicVideoController) => void; + onControllerReady: (controller: DynamicVideoController) => void; onClick?: () => void; }; export default function DynamicVideoPlayer({ @@ -86,14 +86,17 @@ export default function DynamicVideoPlayer({ }, [camera, config, previewOnly]); useEffect(() => { - if (!controller) { + if (!playerRef.current && !previewRef.current) { return; } - if (onControllerReady) { + if (controller) { onControllerReady(controller); } - }, [controller, onControllerReady]); + + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playerRef, previewRef]); const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true); @@ -277,10 +280,6 @@ export default function DynamicVideoPlayer({ player.on("ended", () => controller.fireClipChangeEvent("forward"), ); - - if (onControllerReady) { - onControllerReady(controller); - } }} onDispose={() => { playerRef.current = undefined; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 8e97b5778..60636b470 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -10,6 +10,7 @@ const buttonVariants = cva( variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", + select: "bg-select text-white hover:bg-select/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 9ab090fdd..18d5781e7 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -115,9 +115,7 @@ export default function Events() { // review summary - const { data: reviewSummary, mutate: updateSummary } = useSWR< - ReviewSummary[] - >([ + const { data: reviewSummary, mutate: updateSummary } = useSWR([ "review/summary", { timezone: timezone, @@ -164,7 +162,7 @@ export default function Events() { const markItemAsReviewed = useCallback( async (review: ReviewSegment) => { - const resp = await axios.post(`review/${review.id}/viewed`); + const resp = await axios.post(`reviews/viewed`, { ids: [review.id] }); if (resp.status == 200) { updateSegments( @@ -197,23 +195,30 @@ export default function Events() { ); updateSummary( - (data: ReviewSummary[] | undefined) => { + (data: ReviewSummary | undefined) => { if (!data) { return data; } const day = new Date(review.start_time * 1000); - const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; - const index = data.findIndex((summary) => summary.day == key); + const today = new Date(); + today.setHours(0, 0, 0, 0); - if (index == -1) { + let key; + if (day.getTime() > today.getTime()) { + key = "last24Hours"; + } else { + key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; + } + + if (!Object.keys(data).includes(key)) { return data; } - const item = data[index]; - return [ - ...data.slice(0, index), - { + const item = data[key]; + return { + ...data, + [key]: { ...item, reviewed_alert: review.severity == "alert" @@ -228,8 +233,7 @@ export default function Events() { ? item.reviewed_motion + 1 : item.reviewed_motion, }, - ...data.slice(index + 1), - ]; + }; }, { revalidate: false, populateCache: true }, ); @@ -279,6 +283,11 @@ export default function Events() { return undefined; } + // mark item as reviewed since it has been opened + if (!selectedReview?.has_been_reviewed) { + markItemAsReviewed(selectedReview); + } + return { camera: selectedReview.camera, severity: selectedReview.severity, diff --git a/web/src/types/review.ts b/web/src/types/review.ts index 8e52dc4af..7366738ab 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -28,7 +28,7 @@ export type ReviewFilter = { showReviewed?: 0 | 1; }; -export type ReviewSummary = { +type ReviewSummaryDay = { day: string; reviewed_alert: number; reviewed_detection: number; @@ -38,6 +38,10 @@ export type ReviewSummary = { total_motion: number; }; +export type ReviewSummary = { + [day: string]: ReviewSummaryDay; +}; + export type MotionData = { start_time: number; motion: number; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 1b12952ee..1f51e136c 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -35,10 +35,11 @@ import { LuFolderCheck } from "react-icons/lu"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; +import { Button } from "@/components/ui/button"; type EventViewProps = { reviewPages?: ReviewSegment[][]; - reviewSummary?: ReviewSummary[]; + reviewSummary?: ReviewSummary; relevantPreviews?: Preview[]; timeRange: { before: number; after: number }; reachedEnd: boolean; @@ -74,17 +75,17 @@ export default function EventView({ // review counts const reviewCounts = useMemo(() => { - if (!reviewSummary || reviewSummary.length == 0) { + if (!reviewSummary) { return { alert: 0, detection: 0, significant_motion: 0 }; } let summary; if (filter?.before == undefined) { - summary = reviewSummary[0]; + summary = reviewSummary["last24Hours"]; } else { const day = new Date(filter.before * 1000); const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; - summary = reviewSummary.find((check) => check.day == key); + summary = reviewSummary[key]; } if (!summary) { @@ -211,9 +212,11 @@ export default function EventView({ setSeverity(value)} + value={severity} + onValueChange={(value: ReviewSeverity) => + value ? setSeverity(value) : null + } // don't allow the severity to be unselected > -
- Motion ∙ {reviewCounts.significant_motion} -
+
Motion
@@ -303,6 +304,7 @@ type DetectionReviewProps = { detection: ReviewSegment[]; significant_motion: ReviewSegment[]; }; + itemsToReview?: number; relevantPreviews?: Preview[]; pagingObserver: MutableRefObject; selectedReviews: string[]; @@ -320,6 +322,7 @@ function DetectionReview({ contentRef, currentItems, reviewItems, + itemsToReview, relevantPreviews, pagingObserver, selectedReviews, @@ -359,6 +362,17 @@ function DetectionReview({ [isValidating, pagingObserver, reachedEnd, loadNextPage], ); + const markAllReviewed = useCallback(async () => { + if (!currentItems) { + return; + } + + await axios.post(`reviews/viewed`, { + ids: currentItems?.map((seg) => seg.id), + }); + pullLatestData(); + }, [currentItems, pullLatestData]); + // timeline interaction const { alignStartDateToTimeline } = useEventUtils( @@ -453,7 +467,7 @@ function DetectionReview({ /> )} - {!isValidating && currentItems == null && ( + {(itemsToReview == 0 || (currentItems == null && !isValidating)) && (
There are no {severity.replace(/_/g, " ")} items to review @@ -489,13 +503,27 @@ function DetectionReview({ onClick={onSelectReview} />
- {lastRow && !reachedEnd && } ); }) - ) : severity != "alert" ? ( + ) : itemsToReview != 0 ? (
) : null} + {currentItems && ( +
+ {reachedEnd ? ( + + ) : ( + + )} +
+ )}
@@ -574,6 +602,7 @@ function MotionReview({ before: timeRange.before, after: timeRange.after, scale: segmentDuration / 2, + cameras: filter?.cameras?.join(",") ?? null, }, ]);