diff --git a/frigate/api/review.py b/frigate/api/review.py index ad8a5094b..0ab524580 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -77,6 +77,29 @@ def review_summary(): hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) month_ago = (datetime.now() - timedelta(days=30)).timestamp() + cameras = request.args.get("cameras", "all") + labels = request.args.get("labels", "all") + + clauses = [(ReviewSegment.start_time > month_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)) + groups = ( ReviewSegment.select( fn.strftime( @@ -161,7 +184,7 @@ def review_summary(): ) ).alias("total_motion"), ) - .where(ReviewSegment.start_time > month_ago) + .where(reduce(operator.and_, clauses)) .group_by( (ReviewSegment.start_time + seconds_offset).cast("int") / (3600 * 24), ) diff --git a/frigate/config.py b/frigate/config.py index 3ab7ea956..fa2d5fe85 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -1168,7 +1168,7 @@ class FrigateConfig(FrigateBaseModel): ) cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.") camera_groups: Dict[str, CameraGroupConfig] = Field( - default_factory=CameraGroupConfig, title="Camera group configuration" + default_factory=dict, title="Camera group configuration" ) timestamp_style: TimestampStyleConfig = Field( default_factory=TimestampStyleConfig, diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 6908aec81..d0f84899b 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -25,7 +25,7 @@ type PreviewPlayerProps = { allPreviews?: Preview[]; scrollLock?: boolean; onTimeUpdate?: React.Dispatch>; - setReviewed: (reviewId: string) => void; + setReviewed: (review: ReviewSegment) => void; onClick: (reviewId: string, ctrl: boolean) => void; }; @@ -65,13 +65,13 @@ export default function PreviewThumbnailPlayer({ ); const swipeHandlers = useSwipeable({ - onSwipedLeft: () => (setReviewed ? setReviewed(review.id) : null), + onSwipedLeft: () => (setReviewed ? setReviewed(review) : null), onSwipedRight: () => setPlayback(true), preventScrollOnSwipe: true, }); const handleSetReviewed = useCallback( - () => setReviewed(review.id), + () => setReviewed(review), [review, setReviewed], ); @@ -237,7 +237,7 @@ export default function PreviewThumbnailPlayer({ type PreviewContentProps = { review: ReviewSegment; relevantPreview: Preview | undefined; - setReviewed?: () => void; + setReviewed: () => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; @@ -280,7 +280,7 @@ const PREVIEW_PADDING = 16; type VideoPreviewProps = { review: ReviewSegment; relevantPreview: Preview; - setReviewed?: () => void; + setReviewed: () => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; @@ -366,6 +366,10 @@ function VideoPreview({ setLastPercent(playerPercent); if (playerPercent > 100) { + if (!review.has_been_reviewed) { + setReviewed(); + } + if (isMobile) { isPlayingBack(false); @@ -483,7 +487,7 @@ function VideoPreview({ const MIN_LOAD_TIMEOUT_MS = 200; type InProgressPreviewProps = { review: ReviewSegment; - setReviewed?: (reviewId: string) => void; + setReviewed: (reviewId: string) => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; @@ -518,6 +522,10 @@ function InProgressPreview({ } if (key == previewFrames.length - 1) { + if (!review.has_been_reviewed) { + setReviewed(review.id); + } + if (isMobile) { isPlayingBack(false); diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 3d601e290..ca15c0a3c 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -4,7 +4,12 @@ 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 { + ReviewFilter, + ReviewSegment, + ReviewSeverity, + ReviewSummary, +} from "@/types/review"; import EventView from "@/views/events/EventView"; import RecordingView from "@/views/events/RecordingView"; import axios from "axios"; @@ -104,16 +109,25 @@ export default function Events() { const onLoadNextPage = useCallback(() => setSize(size + 1), [size, setSize]); - const reloadData = useCallback(() => setBeforeTs(Date.now() / 1000), []); - // review summary - const { data: reviewSummary } = useSWR([ + const { data: reviewSummary, mutate: updateSummary } = useSWR< + ReviewSummary[] + >([ "review/summary", - { timezone: timezone }, + { + timezone: timezone, + cameras: reviewSearchParams["cameras"] ?? null, + labels: reviewSearchParams["labels"] ?? null, + }, { revalidateOnFocus: false }, ]); + const reloadData = useCallback(() => { + setBeforeTs(Date.now() / 1000); + updateSummary(); + }, [updateSummary]); + // preview videos const previewTimes = useMemo(() => { @@ -145,8 +159,8 @@ export default function Events() { // review status const markItemAsReviewed = useCallback( - async (reviewId: string) => { - const resp = await axios.post(`review/${reviewId}/viewed`); + async (review: ReviewSegment) => { + const resp = await axios.post(`review/${review.id}/viewed`); if (resp.status == 200) { updateSegments( @@ -158,7 +172,9 @@ export default function Events() { const newData: ReviewSegment[][] = []; data.forEach((page) => { - const reviewIndex = page.findIndex((item) => item.id == reviewId); + const reviewIndex = page.findIndex( + (item) => item.id == review.id, + ); if (reviewIndex == -1) { newData.push([...page]); @@ -175,9 +191,47 @@ export default function Events() { }, { revalidate: false, populateCache: true }, ); + + updateSummary( + (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); + + if (index == -1) { + return data; + } + + const item = data[index]; + return [ + ...data.slice(0, index), + { + ...item, + reviewed_alert: + review.severity == "alert" + ? item.reviewed_alert + 1 + : item.reviewed_alert, + reviewed_detection: + review.severity == "detection" + ? item.reviewed_detection + 1 + : item.reviewed_detection, + reviewed_motion: + review.severity == "significant_motion" + ? item.reviewed_motion + 1 + : item.reviewed_motion, + }, + ...data.slice(index + 1), + ]; + }, + { revalidate: false, populateCache: true }, + ); } }, - [updateSegments], + [updateSegments, updateSummary], ); // selected items diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index e00a10a30..51e35e65f 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -45,7 +45,7 @@ type EventViewProps = { severity: ReviewSeverity; setSeverity: (severity: ReviewSeverity) => void; loadNextPage: () => void; - markItemAsReviewed: (reviewId: string) => void; + markItemAsReviewed: (review: ReviewSegment) => void; onOpenReview: (reviewId: string) => void; pullLatestData: () => void; updateFilter: (filter: ReviewFilter) => void; @@ -72,7 +72,7 @@ export default function EventView({ // review counts const reviewCounts = useMemo(() => { - if (!reviewSummary) { + if (!reviewSummary || reviewSummary.length == 0) { return { alert: 0, detection: 0, significant_motion: 0 }; } @@ -80,7 +80,13 @@ export default function EventView({ if (filter?.before == undefined) { summary = reviewSummary[0]; } else { - summary = reviewSummary[0]; + 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); + } + + if (!summary) { + return { alert: 0, detection: 0, significant_motion: 0 }; } if (filter?.showReviewed == 1) { @@ -303,7 +309,7 @@ type DetectionReviewProps = { reachedEnd: boolean; timeRange: { before: number; after: number }; loadNextPage: () => void; - markItemAsReviewed: (id: string) => void; + markItemAsReviewed: (review: ReviewSegment) => void; onSelectReview: (id: string, ctrl: boolean) => void; pullLatestData: () => void; };