mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									fa0f509e18
								
							
						
					
					
						commit
						d3f9fd1a60
					
				@ -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__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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/<id>/viewed", methods=("POST",))
 | 
			
		||||
def set_reviewed(id):
 | 
			
		||||
    try:
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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<FrigateConfig>("config");
 | 
			
		||||
  const timezone = useTimezone(config);
 | 
			
		||||
 | 
			
		||||
  // recordings viewer
 | 
			
		||||
 | 
			
		||||
  const [severity, setSeverity] = useState<ReviewSeverity>("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 <ActivityIndicator />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedData) {
 | 
			
		||||
    return (
 | 
			
		||||
      <DesktopRecordingView
 | 
			
		||||
@ -212,6 +230,7 @@ export default function Events() {
 | 
			
		||||
    return (
 | 
			
		||||
      <EventView
 | 
			
		||||
        reviewPages={reviewPages}
 | 
			
		||||
        reviewSummary={reviewSummary}
 | 
			
		||||
        relevantPreviews={allPreviews}
 | 
			
		||||
        timeRange={selectedTimeRange}
 | 
			
		||||
        reachedEnd={isDone}
 | 
			
		||||
 | 
			
		||||
@ -27,3 +27,13 @@ export type ReviewFilter = {
 | 
			
		||||
  after?: number;
 | 
			
		||||
  showReviewed?: 0 | 1;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ReviewSummary = {
 | 
			
		||||
  day: string;
 | 
			
		||||
  reviewed_alert: number;
 | 
			
		||||
  reviewed_detection: number;
 | 
			
		||||
  reviewed_motion: number;
 | 
			
		||||
  total_alert: number;
 | 
			
		||||
  total_detection: number;
 | 
			
		||||
  total_motion: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,12 @@ import { useEventUtils } from "@/hooks/use-event-utils";
 | 
			
		||||
import { useScrollLockout } from "@/hooks/use-mouse-listener";
 | 
			
		||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
			
		||||
import { Preview } from "@/types/preview";
 | 
			
		||||
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
 | 
			
		||||
import {
 | 
			
		||||
  ReviewFilter,
 | 
			
		||||
  ReviewSegment,
 | 
			
		||||
  ReviewSeverity,
 | 
			
		||||
  ReviewSummary,
 | 
			
		||||
} from "@/types/review";
 | 
			
		||||
import axios from "axios";
 | 
			
		||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 | 
			
		||||
import { isDesktop, isMobile } from "react-device-detect";
 | 
			
		||||
@ -20,6 +25,7 @@ import useSWR from "swr";
 | 
			
		||||
 | 
			
		||||
type EventViewProps = {
 | 
			
		||||
  reviewPages?: ReviewSegment[][];
 | 
			
		||||
  reviewSummary?: ReviewSummary[];
 | 
			
		||||
  relevantPreviews?: Preview[];
 | 
			
		||||
  timeRange: { before: number; after: number };
 | 
			
		||||
  reachedEnd: boolean;
 | 
			
		||||
@ -35,6 +41,7 @@ type EventViewProps = {
 | 
			
		||||
};
 | 
			
		||||
export default function EventView({
 | 
			
		||||
  reviewPages,
 | 
			
		||||
  reviewSummary,
 | 
			
		||||
  relevantPreviews,
 | 
			
		||||
  timeRange,
 | 
			
		||||
  reachedEnd,
 | 
			
		||||
@ -52,6 +59,35 @@ export default function EventView({
 | 
			
		||||
  const contentRef = useRef<HTMLDivElement | null>(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"
 | 
			
		||||
          >
 | 
			
		||||
            <MdCircle className="size-2 md:mr-[10px] text-severity_alert" />
 | 
			
		||||
            <div className="hidden md:block">Alerts</div>
 | 
			
		||||
            <div className="hidden md:block">Alerts ∙ {reviewCounts.alert}</div>
 | 
			
		||||
          </ToggleGroupItem>
 | 
			
		||||
          <ToggleGroupItem
 | 
			
		||||
            className={`${severity == "detection" ? "" : "text-gray-500"}`}
 | 
			
		||||
@ -272,7 +308,9 @@ export default function EventView({
 | 
			
		||||
            aria-label="Select detections"
 | 
			
		||||
          >
 | 
			
		||||
            <MdCircle className="size-2 md:mr-[10px] text-severity_detection" />
 | 
			
		||||
            <div className="hidden md:block">Detections</div>
 | 
			
		||||
            <div className="hidden md:block">
 | 
			
		||||
              Detections ∙ {reviewCounts.detection}
 | 
			
		||||
            </div>
 | 
			
		||||
          </ToggleGroupItem>
 | 
			
		||||
          <ToggleGroupItem
 | 
			
		||||
            className={`px-3 py-4 rounded-2xl ${
 | 
			
		||||
@ -282,7 +320,9 @@ export default function EventView({
 | 
			
		||||
            aria-label="Select motion"
 | 
			
		||||
          >
 | 
			
		||||
            <MdCircle className="size-2 md:mr-[10px] text-severity_motion" />
 | 
			
		||||
            <div className="hidden md:block">Motion</div>
 | 
			
		||||
            <div className="hidden md:block">
 | 
			
		||||
              Motion ∙ {reviewCounts.significant_motion}
 | 
			
		||||
            </div>
 | 
			
		||||
          </ToggleGroupItem>
 | 
			
		||||
        </ToggleGroup>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user