import ActivityIndicator from "@/components/indicators/activity-indicator"; import useApiFilter from "@/hooks/use-api-filter"; import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useTimezone } from "@/hooks/use-date-utils"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { RecordingStartingPoint } from "@/types/record"; import { REVIEW_PADDING, ReviewFilter, ReviewSegment, ReviewSeverity, ReviewSummary, SegmentedReviewData, } from "@/types/review"; import EventView from "@/views/events/EventView"; import { RecordingView } from "@/views/events/RecordingView"; import axios from "axios"; import { useCallback, useEffect, useMemo, useState } from "react"; import useSWR from "swr"; export default function Events() { const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const timezone = useTimezone(config); // recordings viewer const [severity, setSeverity] = useOverlayState( "severity", "alert", ); const [recording, setRecording] = useOverlayState("recording"); useSearchEffect("id", (reviewId: string) => { axios .get(`review/${reviewId}`) .then((resp) => { if (resp.status == 200 && resp.data) { setRecording( { camera: resp.data.camera, startTime: resp.data.start_time - REVIEW_PADDING, severity: resp.data.severity, }, true, ); } }) .catch(() => {}); }); const [startTime, setStartTime] = useState(); useEffect(() => { if (recording) { document.title = "Recordings - Frigate"; } else { document.title = `Review - Frigate`; } }, [recording, severity]); // review filter const [reviewFilter, setReviewFilter, reviewSearchParams] = useApiFilter(); useSearchEffect("group", (reviewGroup) => { if (config && reviewGroup && reviewGroup != "default") { const group = config.camera_groups[reviewGroup]; const isBirdseyeOnly = group.cameras.length == 1 && group.cameras[0] == "birdseye"; if (group && !isBirdseyeOnly) { setReviewFilter({ ...reviewFilter, cameras: group.cameras, }); } } }); const onUpdateFilter = useCallback( (newFilter: ReviewFilter) => { setReviewFilter(newFilter); // update recording start time if filter // was changed on recording page if (recording != undefined && newFilter.after != undefined) { setRecording({ ...recording, startTime: newFilter.after }, true); } }, [recording, setRecording, setReviewFilter], ); // review paging const [beforeTs, setBeforeTs] = useState(Date.now() / 1000); const last24Hours = useMemo(() => { return { before: beforeTs, after: getHoursAgo(24) }; }, [beforeTs]); const selectedTimeRange = useMemo(() => { if (reviewSearchParams["after"] == undefined) { return last24Hours; } return { before: Math.floor(reviewSearchParams["before"]), after: Math.floor(reviewSearchParams["after"]), }; }, [last24Hours, reviewSearchParams]); // we want to update the items whenever the severity changes useEffect(() => { if (recording) { return; } const now = Date.now() / 1000; if (now - beforeTs > 60) { setBeforeTs(now); } // only refresh when severity changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [severity]); const reviewSegmentFetcher = useCallback((key: Array | string) => { const [path, params] = Array.isArray(key) ? key : [key, undefined]; return axios.get(path, { params }).then((res) => res.data); }, []); const getKey = useCallback(() => { const params = { cameras: reviewSearchParams["cameras"], labels: reviewSearchParams["labels"], zones: reviewSearchParams["zones"], reviewed: 1, before: reviewSearchParams["before"] || last24Hours.before, after: reviewSearchParams["after"] || last24Hours.after, }; return ["review", params]; }, [reviewSearchParams, last24Hours]); const { data: reviews, mutate: updateSegments } = useSWR( getKey, reviewSegmentFetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, }, ); const reviewItems = useMemo(() => { if (!reviews) { return undefined; } const all: ReviewSegment[] = []; const alerts: ReviewSegment[] = []; const detections: ReviewSegment[] = []; const motion: ReviewSegment[] = []; reviews?.forEach((segment) => { all.push(segment); switch (segment.severity) { case "alert": alerts.push(segment); break; case "detection": detections.push(segment); break; default: motion.push(segment); break; } }); return { all: all, alert: alerts, detection: detections, significant_motion: motion, }; }, [reviews]); const currentItems = useMemo(() => { if (!reviewItems || !severity) { return null; } let current; if (reviewFilter?.showAll) { current = reviewItems.all; } else { current = reviewItems[severity]; } if (!current || current.length == 0) { return []; } if (reviewFilter?.showReviewed != 1) { return current.filter((seg) => !seg.has_been_reviewed); } else { return current; } // only refresh when severity or filter changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [severity, reviewFilter, reviewItems?.all.length]); // review summary const { data: reviewSummary, mutate: updateSummary } = useSWR( [ "review/summary", { timezone: timezone, cameras: reviewSearchParams["cameras"] ?? null, labels: reviewSearchParams["labels"] ?? null, zones: reviewSearchParams["zones"] ?? null, }, ], { revalidateOnFocus: true, refreshInterval: 30000, revalidateOnReconnect: false, }, ); const reloadData = useCallback(() => { setBeforeTs(Date.now() / 1000); updateSummary(); }, [updateSummary]); // preview videos const previewTimes = useMemo(() => { const startDate = new Date(selectedTimeRange.after * 1000); startDate.setUTCMinutes(0, 0, 0); const endDate = new Date(selectedTimeRange.before * 1000); endDate.setHours(endDate.getHours() + 1, 0, 0, 0); return { after: startDate.getTime() / 1000, before: endDate.getTime() / 1000, }; }, [selectedTimeRange]); const allPreviews = useCameraPreviews( previewTimes ?? { after: 0, before: 0 }, { fetchPreviews: previewTimes != undefined, }, ); // review status const markAllItemsAsReviewed = useCallback( async (currentItems: ReviewSegment[]) => { if (currentItems.length == 0) { return; } const severity = currentItems[0].severity; updateSegments( (data: ReviewSegment[] | undefined) => { if (!data) { return data; } const newData = [...data]; newData.forEach((seg) => { if (seg.end_time && seg.severity == severity) { seg.has_been_reviewed = true; } }); return newData; }, { revalidate: false, populateCache: true }, ); const itemsToMarkReviewed = currentItems ?.filter((seg) => seg.end_time) ?.map((seg) => seg.id); if (itemsToMarkReviewed.length > 0) { await axios.post(`reviews/viewed`, { ids: itemsToMarkReviewed, }); reloadData(); } }, [reloadData, updateSegments], ); const markItemAsReviewed = useCallback( async (review: ReviewSegment) => { const resp = await axios.post(`reviews/viewed`, { ids: [review.id] }); if (resp.status == 200) { updateSegments( (data: ReviewSegment[] | undefined) => { if (!data) { return data; } const reviewIndex = data.findIndex((item) => item.id == review.id); if (reviewIndex == -1) { return data; } const newData = [ ...data.slice(0, reviewIndex), { ...data[reviewIndex], has_been_reviewed: true }, ...data.slice(reviewIndex + 1), ]; return newData; }, { revalidate: false, populateCache: true }, ); updateSummary( (data: ReviewSummary | undefined) => { if (!data) { return data; } const day = new Date(review.start_time * 1000); const today = new Date(); today.setHours(0, 0, 0, 0); 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[key]; return { ...data, [key]: { ...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, }, }; }, { revalidate: false, populateCache: true }, ); } }, [updateSegments, updateSummary], ); // selected items const selectedReviewData = useMemo(() => { if (!recording) { return undefined; } if (!config) { return undefined; } if (!reviews) { return undefined; } setStartTime(recording.startTime); const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras); return { camera: recording.camera, start_time: recording.startTime, allCameras: allCameras, }; // previews will not update after item is selected // eslint-disable-next-line react-hooks/exhaustive-deps }, [recording, reviews]); if (!timezone) { return ; } if (recording) { if (selectedReviewData) { return ( ); } } else { return ( ); } } function getHoursAgo(hours: number): number { const now = new Date(); now.setHours(now.getHours() - hours); return now.getTime() / 1000; }