diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bd0dca385..498877bb5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -296,6 +296,10 @@ jobs: run: | echo "Failed to restore pre-commit environment from cache" exit 1 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends python3-gi python3-gst-1.0 - name: Run pylint uses: ./.github/templates/run_in_venv with: diff --git a/.pylintrc b/.pylintrc index 02e4f4ab3..a3367d723 100644 --- a/.pylintrc +++ b/.pylintrc @@ -16,7 +16,7 @@ ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). -#init-hook= +init-hook=import gi; gi.require_version('Gst', '1.0'); from gi.repository import Gst; Gst.init() # Use multiple processes to speed up Pylint. # jobs=2 diff --git a/docs/src/pages/components-explorer/components/ffmpeg/config.json b/docs/src/pages/components-explorer/components/ffmpeg/config.json index cae54f611..21f8617fe 100644 --- a/docs/src/pages/components-explorer/components/ffmpeg/config.json +++ b/docs/src/pages/components-explorer/components/ffmpeg/config.json @@ -785,6 +785,47 @@ "optional": true, "default": false }, + { + "type": "map", + "value": [ + { + "type": "integer", + "valueMin": 0, + "name": "days", + "description": "Days between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "hours", + "description": "Hours between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "minutes", + "description": "Minutes between checks for files to move/delete.", + "optional": true, + "default": 1 + }, + { + "type": "integer", + "valueMin": 0, + "name": "seconds", + "description": "Seconds between checks for files to move/delete.", + "optional": true, + "default": 0 + } + ], + "name": "check_interval", + "description": "How often to check for files to move to the next tier.", + "optional": true, + "default": null + }, { "type": "map", "value": [ diff --git a/docs/src/pages/components-explorer/components/gstreamer/config.json b/docs/src/pages/components-explorer/components/gstreamer/config.json index efb351223..e08eebd91 100644 --- a/docs/src/pages/components-explorer/components/gstreamer/config.json +++ b/docs/src/pages/components-explorer/components/gstreamer/config.json @@ -528,6 +528,47 @@ "optional": true, "default": false }, + { + "type": "map", + "value": [ + { + "type": "integer", + "valueMin": 0, + "name": "days", + "description": "Days between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "hours", + "description": "Hours between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "minutes", + "description": "Minutes between checks for files to move/delete.", + "optional": true, + "default": 1 + }, + { + "type": "integer", + "valueMin": 0, + "name": "seconds", + "description": "Seconds between checks for files to move/delete.", + "optional": true, + "default": 0 + } + ], + "name": "check_interval", + "description": "How often to check for files to move to the next tier.", + "optional": true, + "default": null + }, { "type": "map", "value": [ diff --git a/docs/src/pages/components-explorer/components/storage/config.json b/docs/src/pages/components-explorer/components/storage/config.json index 56adf321c..ac3d9dea0 100644 --- a/docs/src/pages/components-explorer/components/storage/config.json +++ b/docs/src/pages/components-explorer/components/storage/config.json @@ -30,6 +30,47 @@ "optional": true, "default": false }, + { + "type": "map", + "value": [ + { + "type": "integer", + "valueMin": 0, + "name": "days", + "description": "Days between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "hours", + "description": "Hours between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "minutes", + "description": "Minutes between checks for files to move/delete.", + "optional": true, + "default": 1 + }, + { + "type": "integer", + "valueMin": 0, + "name": "seconds", + "description": "Seconds between checks for files to move/delete.", + "optional": true, + "default": 0 + } + ], + "name": "check_interval", + "description": "How often to check for files to move to the next tier.", + "optional": true, + "default": null + }, { "type": "map", "value": [ @@ -416,6 +457,47 @@ "description": "Move/delete files to the next tier when Viseron shuts down. Useful to not lose files when shutting down Viseron if using a RAM disk.", "optional": true, "default": false + }, + { + "type": "map", + "value": [ + { + "type": "integer", + "valueMin": 0, + "name": "days", + "description": "Days between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "hours", + "description": "Hours between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "minutes", + "description": "Minutes between checks for files to move/delete.", + "optional": true, + "default": 1 + }, + { + "type": "integer", + "valueMin": 0, + "name": "seconds", + "description": "Seconds between checks for files to move/delete.", + "optional": true, + "default": 0 + } + ], + "name": "check_interval", + "description": "How often to check for files to move to the next tier.", + "optional": true, + "default": null } ] ], @@ -565,6 +647,47 @@ "description": "Move/delete files to the next tier when Viseron shuts down. Useful to not lose files when shutting down Viseron if using a RAM disk.", "optional": true, "default": false + }, + { + "type": "map", + "value": [ + { + "type": "integer", + "valueMin": 0, + "name": "days", + "description": "Days between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "hours", + "description": "Hours between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "minutes", + "description": "Minutes between checks for files to move/delete.", + "optional": true, + "default": 1 + }, + { + "type": "integer", + "valueMin": 0, + "name": "seconds", + "description": "Seconds between checks for files to move/delete.", + "optional": true, + "default": 0 + } + ], + "name": "check_interval", + "description": "How often to check for files to move to the next tier.", + "optional": true, + "default": null } ] ], @@ -713,6 +836,47 @@ "description": "Move/delete files to the next tier when Viseron shuts down. Useful to not lose files when shutting down Viseron if using a RAM disk.", "optional": true, "default": false + }, + { + "type": "map", + "value": [ + { + "type": "integer", + "valueMin": 0, + "name": "days", + "description": "Days between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "hours", + "description": "Hours between checks for files to move/delete.", + "optional": true, + "default": 0 + }, + { + "type": "integer", + "valueMin": 0, + "name": "minutes", + "description": "Minutes between checks for files to move/delete.", + "optional": true, + "default": 1 + }, + { + "type": "integer", + "valueMin": 0, + "name": "seconds", + "description": "Seconds between checks for files to move/delete.", + "optional": true, + "default": 0 + } + ], + "name": "check_interval", + "description": "How often to check for files to move to the next tier.", + "optional": true, + "default": null } ] ], diff --git a/frontend/src/components/events/EventPlayerCard.tsx b/frontend/src/components/events/EventPlayerCard.tsx index 334033253..a13397846 100644 --- a/frontend/src/components/events/EventPlayerCard.tsx +++ b/frontend/src/components/events/EventPlayerCard.tsx @@ -3,118 +3,18 @@ import CardMedia from "@mui/material/CardMedia"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import Hls from "hls.js"; -import { useEffect, useRef } from "react"; -import videojs from "video.js"; -import Player from "video.js/dist/types/player"; import { CameraNameOverlay } from "components/camera/CameraNameOverlay"; import { TimelinePlayer } from "components/events/timeline/TimelinePlayer"; +import { getSrc } from "components/events/utils"; import VideoPlayerPlaceholder from "components/videoplayer/VideoPlayerPlaceholder"; -import { useAuthContext } from "context/AuthContext"; -import { getAuthHeader } from "lib/tokens"; import * as types from "lib/types"; dayjs.extend(utc); -const _videoJsOptions = { - autoplay: true, - playsinline: true, - controls: true, - loop: true, - preload: undefined, - responsive: true, - fluid: false, - aspectRatio: "16:9", - fill: true, - playbackRates: [0.5, 1, 2, 5, 10], - liveui: true, - liveTracker: { - trackingThreshold: 0, - }, - html5: { - vhs: { - experimentalLLHLS: true, - }, - }, -}; - -const useInitializePlayer = ( - videoNode: React.RefObject, - player: React.MutableRefObject, - source: string, -) => { - const { auth } = useAuthContext(); - - useEffect(() => { - if (!player.current) { - const separator = source.includes("?") ? "&" : "?"; - player.current = videojs( - videoNode.current!, - { - ..._videoJsOptions, - sources: [ - { - src: - source + (auth ? `${separator}token=${getAuthHeader()}` : ""), - type: "application/x-mpegURL", - }, - ], - }, - () => {}, - ); - } - return () => { - if (player.current) { - player.current.dispose(); - } - }; - // Must disable this warning since we dont want to ever run this twice - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); -}; - -const useSourceChange = ( - player: React.MutableRefObject, - source: string, -) => { - const { auth } = useAuthContext(); - - useEffect(() => { - if (player.current) { - const separator = source.includes("?") ? "&" : "?"; - player.current.reset(); - player.current.src([ - { - src: source + (auth ? `${separator}token=${getAuthHeader()}` : ""), - type: "application/x-mpegURL", - }, - ]); - player.current.load(); - } - }, [auth, player, source]); -}; - -type EventPlayerProps = { - source: string; -}; - -const EventPlayer = ({ source }: EventPlayerProps) => { - const videoNode = useRef(null); - const player = useRef(); - - useInitializePlayer(videoNode, player, source); - useSourceChange(player, source); - - return ( -
-
- ); -}; - type PlayerCardProps = { camera: types.Camera | types.FailedCamera | null; - eventSource: string | null; + selectedEvent: types.CameraEvent | null; requestedTimestamp: number | null; selectedTab: "events" | "timeline"; hlsRef: React.MutableRefObject; @@ -123,37 +23,45 @@ type PlayerCardProps = { export const PlayerCard = ({ camera, - eventSource, + selectedEvent, requestedTimestamp, - selectedTab, hlsRef, playerCardRef, -}: PlayerCardProps) => ( - ({ - marginBottom: theme.margin, - position: "relative", - })} - > - {camera && } - - {eventSource && selectedTab === "events" ? ( - - ) : camera && requestedTimestamp && selectedTab === "timeline" ? ( - - ) : ( - - )} - - -); +}: PlayerCardProps) => { + const src = camera && selectedEvent ? getSrc(selectedEvent) : undefined; + + return ( + ({ + marginBottom: theme.margin, + position: "relative", + })} + > + {camera && } + + {camera && requestedTimestamp ? ( + + ) : ( + + )} + + + ); +}; diff --git a/frontend/src/components/events/EventTable.tsx b/frontend/src/components/events/EventTable.tsx index dddb949b4..3aa7515dd 100644 --- a/frontend/src/components/events/EventTable.tsx +++ b/frontend/src/components/events/EventTable.tsx @@ -9,11 +9,51 @@ import ServerDown from "svg/undraw/server_down.svg?react"; import { ErrorMessage } from "components/error/ErrorMessage"; import { EventTableItem } from "components/events/EventTableItem"; +import { filterEvents } from "components/events/utils"; import { Loading } from "components/loading/Loading"; -import { useRecordings } from "lib/api/recordings"; -import { throttle } from "lib/helpers"; +import { useEvents } from "lib/api/events"; +import { useHlsAvailableTimespans } from "lib/api/hls"; +import { objIsEmpty, throttle } from "lib/helpers"; import * as types from "lib/types"; +// Groups that are within 2 minutes of each other +const groupSnapshotEventsByTime = ( + snapshotEvents: types.CameraSnapshotEvents, +): types.CameraSnapshotEvents[] => { + if (snapshotEvents.length === 0) { + return []; + } + + snapshotEvents.reverse(); + + const groups: types.CameraSnapshotEvents[] = []; + let currentGroup: types.CameraSnapshotEvents = []; + let startOfGroup = snapshotEvents[0].timestamp; + + for (let i = 0; i < snapshotEvents.length; i++) { + if (currentGroup.length === 0) { + currentGroup.push(snapshotEvents[i]); + } else { + const currentTime = snapshotEvents[i].timestamp; + + if (currentTime - startOfGroup < 120) { + currentGroup.push(snapshotEvents[i]); + } else { + startOfGroup = snapshotEvents[i].timestamp; + groups.push(currentGroup); + currentGroup = [snapshotEvents[i]]; + } + } + } + + // Add the last group if it has any items + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups.reverse(); +}; + const useOnScroll = (parentRef: React.RefObject) => { useEffect(() => { const container = parentRef.current; @@ -34,8 +74,9 @@ type EventTableProps = { parentRef: React.RefObject; camera: types.Camera | types.FailedCamera; date: Dayjs | null; - selectedRecording: types.Recording | null; - setSelectedRecording: (recording: types.Recording) => void; + selectedEvent: types.CameraEvent | null; + setSelectedEvent: (event: types.CameraEvent) => void; + setRequestedTimestamp: (timestamp: number | null) => void; }; export const EventTable = memo( @@ -43,24 +84,32 @@ export const EventTable = memo( parentRef, camera, date, - selectedRecording, - setSelectedRecording, + selectedEvent, + setSelectedEvent, + setRequestedTimestamp, }: EventTableProps) => { const formattedDate = dayjs(date).format("YYYY-MM-DD"); - const recordingsQuery = useRecordings({ + const eventsQuery = useEvents({ + camera_identifier: camera.identifier, + date: formattedDate, + configOptions: { enabled: !!date }, + }); + + const availableTimespansQuery = useHlsAvailableTimespans({ camera_identifier: camera.identifier, - failed: camera.failed, date: formattedDate, configOptions: { enabled: !!date }, }); useOnScroll(parentRef); - if (recordingsQuery.isError) { + if (eventsQuery.isError || availableTimespansQuery.isError) { return ( } @@ -68,56 +117,49 @@ export const EventTable = memo( ); } - if (recordingsQuery.isLoading) { - return ; + if (eventsQuery.isLoading || availableTimespansQuery.isLoading) { + return ; } - if (!recordingsQuery.data) { + const groupedEvents = groupSnapshotEventsByTime( + filterEvents(eventsQuery.data.events), + ); + + if (!eventsQuery.data || objIsEmpty(groupedEvents)) { return ( - - } - /> + + No Events found for {formattedDate} + ); } return ( - {formattedDate in recordingsQuery.data ? ( - - {Object.values(recordingsQuery.data[formattedDate]) - .sort() - .reverse() - .map((recording) => ( - - - - - ))} - - ) : ( - - No recordings found for {formattedDate} - - )} + + {groupedEvents.map((snapshotEvents) => ( + + + + + ))} + ); }, diff --git a/frontend/src/components/events/EventTableItem.tsx b/frontend/src/components/events/EventTableItem.tsx index 315df8bc2..67cc2c39e 100644 --- a/frontend/src/components/events/EventTableItem.tsx +++ b/frontend/src/components/events/EventTableItem.tsx @@ -1,4 +1,5 @@ import Image from "@jy95/material-ui-image"; +import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import CardActionArea from "@mui/material/CardActionArea"; import CardMedia from "@mui/material/CardMedia"; @@ -7,21 +8,74 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import LazyLoad from "react-lazyload"; +import { SnapshotIcon } from "components/events/SnapshotEvent"; +import { + extractUniqueLabels, + extractUniqueTypes, + getSrc, +} from "components/events/utils"; import VideoPlayerPlaceholder from "components/videoplayer/VideoPlayerPlaceholder"; import { getTimeFromDate } from "lib/helpers"; import * as types from "lib/types"; +const getText = (snapshotEvents: types.CameraSnapshotEvents) => { + const uniqueEvents = extractUniqueTypes(snapshotEvents); + return ( + + {`${getTimeFromDate( + new Date(snapshotEvents[0].time), + )}`} + + {Object.keys(uniqueEvents).map((key) => { + // For object detection we want to group by label + if (key === "object") { + const uniqueLabels = extractUniqueLabels( + uniqueEvents[key] as Array, + ); + return Object.keys(uniqueLabels).map((label) => ( + + + + )); + } + return ( + + + + ); + })} + + + ); +}; + +const isTimespanAvailable = ( + timestamp: number, + availableTimespans: types.HlsAvailableTimespans, +) => { + for (const timespan of availableTimespans.timespans) { + if (timestamp >= timespan.start && timestamp <= timespan.end) { + return true; + } + } + return false; +}; + type EventTableItemProps = { camera: types.Camera | types.FailedCamera; - recording: types.Recording; - setSelectedRecording: (recording: types.Recording) => void; + snapshotEvents: types.CameraSnapshotEvents; + setSelectedEvent: (event: types.CameraEvent) => void; selected: boolean; + setRequestedTimestamp: (timestamp: number | null) => void; + availableTimespans: types.HlsAvailableTimespans; }; export const EventTableItem = ({ camera, - recording, - setSelectedRecording, + snapshotEvents, + setSelectedEvent, selected, + setRequestedTimestamp, + availableTimespans, }: EventTableItemProps) => { const theme = useTheme(); return ( @@ -36,7 +90,23 @@ export const EventTableItem = ({ boxShadow: "none", }} > - setSelectedRecording(recording)}> + { + if ( + isTimespanAvailable( + Math.round(snapshotEvents[0].timestamp), + availableTimespans, + ) + ) { + setSelectedEvent(snapshotEvents[0]); + setRequestedTimestamp(Math.round(snapshotEvents[0].timestamp)); + return; + } + + setSelectedEvent(snapshotEvents[0]); + setRequestedTimestamp(null); + }} + > - - {getTimeFromDate(new Date(recording.start_time))} - + {getText(snapshotEvents)} void; selectedCamera: types.Camera | types.FailedCamera | null; - selectedRecording: types.Recording | null; - setSelectedRecording: (recording: types.Recording) => void; + selectedEvent: types.CameraEvent | null; + setSelectedEvent: (event: types.CameraEvent) => void; setRequestedTimestamp: (timestamp: number | null) => void; }; const Tabs = ({ @@ -130,8 +130,8 @@ const Tabs = ({ selectedTab, setSelectedTab, selectedCamera, - selectedRecording, - setSelectedRecording, + selectedEvent, + setSelectedEvent, setRequestedTimestamp, }: TabsProps) => { const handleTabChange = ( @@ -166,8 +166,9 @@ const Tabs = ({ parentRef={parentRef} camera={selectedCamera} date={date} - selectedRecording={selectedRecording} - setSelectedRecording={setSelectedRecording} + selectedEvent={selectedEvent} + setSelectedEvent={setSelectedEvent} + setRequestedTimestamp={setRequestedTimestamp} /> ) : ( @@ -199,8 +200,8 @@ const Tabs = ({ type LayoutProps = { cameras: types.CamerasOrFailedCameras; selectedCamera: types.Camera | types.FailedCamera | null; - selectedRecording: types.Recording | null; - setSelectedRecording: (recording: types.Recording) => void; + selectedEvent: types.CameraEvent | null; + setSelectedEvent: (event: types.CameraEvent) => void; changeSelectedCamera: ( ev: React.MouseEvent, camera: types.Camera | types.FailedCamera, @@ -217,8 +218,8 @@ export const Layout = memo( ({ cameras, selectedCamera, - selectedRecording, - setSelectedRecording, + selectedEvent, + setSelectedEvent, changeSelectedCamera, date, setDate, @@ -243,7 +244,7 @@ export const Layout = memo( > @@ -296,8 +297,8 @@ export const LayoutSmall = memo( ({ cameras, selectedCamera, - selectedRecording, - setSelectedRecording, + selectedEvent, + setSelectedEvent, changeSelectedCamera, date, setDate, @@ -334,7 +335,7 @@ export const LayoutSmall = memo(
diff --git a/frontend/src/components/events/SnapshotEvent.tsx b/frontend/src/components/events/SnapshotEvent.tsx new file mode 100644 index 000000000..753b2756f --- /dev/null +++ b/frontend/src/components/events/SnapshotEvent.tsx @@ -0,0 +1,279 @@ +import Image from "@jy95/material-ui-image"; +import DirectionsCarIcon from "@mui/icons-material/DirectionsCar"; +import PersonIcon from "@mui/icons-material/DirectionsWalk"; +import FaceIcon from "@mui/icons-material/Face"; +import ImageSearchIcon from "@mui/icons-material/ImageSearch"; +import PetsIcon from "@mui/icons-material/Pets"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import CardMedia from "@mui/material/CardMedia"; +import Grid from "@mui/material/Grid"; +import Stack from "@mui/material/Stack"; +import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip"; +import { styled, useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { memo } from "react"; + +import { + EVENT_ICON_HEIGHT, + TICK_HEIGHT, + convertToPercentage, + extractUniqueLabels, + extractUniqueTypes, +} from "components/events/utils"; +import { toTitleCase } from "lib/helpers"; +import * as types from "lib/types"; + +const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(() => ({ + [`& .${tooltipClasses.tooltip}`]: { + overflowX: "hidden", + overflowY: "scroll", + maxHeight: "50vh", + maxWidth: "100vw", + }, +})); + +const labelToIcon = (label: string) => { + switch (label) { + case "person": + return PersonIcon; + + case "car": + case "truck": + case "vehicle": + return DirectionsCarIcon; + + case "dog": + case "cat": + case "animal": + return PetsIcon; + + default: + return ImageSearchIcon; + } +}; + +const getIcon = (snapshotEvent: types.CameraSnapshotEvent) => { + switch (snapshotEvent.type) { + case "object": + return labelToIcon(snapshotEvent.label); + case "face_recognition": + return FaceIcon; + default: + return ImageSearchIcon; + } +}; + +const getText = (snapshotEvent: types.CameraSnapshotEvent) => { + const date = new Date(snapshotEvent.time); + switch (snapshotEvent.type) { + case "object": + return ( + + Object Detection + {`Label: ${snapshotEvent.label}`} + {`Confidence: ${convertToPercentage( + snapshotEvent.confidence, + )}%`} + {`Timestamp: ${date.toLocaleString()}`} + + ); + + case "face_recognition": + return ( + + Face Recognition + {`Name: ${toTitleCase(snapshotEvent.data.name)}`} + {`Confidence: ${convertToPercentage( + snapshotEvent.data.confidence, + )}%`} + {`Timestamp: ${date.toLocaleString()}`} + + ); + + default: + return null; + } +}; + +const ToolTipContent = ({ + snapshotEvents, +}: { + snapshotEvents: types.CameraSnapshotEvents; +}) => { + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.up("sm")); + const width = matches + ? snapshotEvents.length > 1 + ? "50vw" + : "25vw" + : "90vw"; + return ( + + {snapshotEvents.reverse().map((snapshotEvent, index) => ( + 1 ? 1 : 2} + > + + + + + {getText(snapshotEvent)} + + + ))} + + ); +}; + +const Divider = () => ( + ({ + height: "1px", + flexGrow: 1, + backgroundColor: theme.palette.divider, + })} + /> +); + +export const SnapshotIcon = ({ + snapshotEvents, +}: { + snapshotEvents: types.CameraSnapshotEvents; +}) => { + const theme = useTheme(); + const Icon = getIcon(snapshotEvents[0]); + return ( + } + arrow + > + + + + + ); +}; + +const SnapshotIcons = ({ + snapshotEvents, +}: { + snapshotEvents: types.CameraSnapshotEvents; +}) => { + const uniqueEvents = extractUniqueTypes(snapshotEvents); + return ( + + {Object.keys(uniqueEvents).map((key) => { + // For object detection we want to group by label + if (key === "object") { + const uniqueLabels = extractUniqueLabels( + uniqueEvents[key] as Array, + ); + return Object.keys(uniqueLabels).map((label) => ( + + + + )); + } + return ( + + + + ); + })} + + ); +}; + +const Snapshot = ({ snapshotPath }: { snapshotPath: string }) => { + const theme = useTheme(); + return ( + + + + ); +}; + +type SnapshotEventProps = { + snapshotEvents: types.CameraSnapshotEvents; +}; +export const SnapshotEvent = memo(({ snapshotEvents }: SnapshotEventProps) => ( + + + + + + +)); diff --git a/frontend/src/components/events/timeline/ActivityLine.tsx b/frontend/src/components/events/timeline/ActivityLine.tsx index aec17ef44..f75276d90 100644 --- a/frontend/src/components/events/timeline/ActivityLine.tsx +++ b/frontend/src/components/events/timeline/ActivityLine.tsx @@ -2,7 +2,7 @@ import Tooltip from "@mui/material/Tooltip"; import { useTheme } from "@mui/material/styles"; import { memo } from "react"; -import { TICK_HEIGHT } from "components/events/timeline/utils"; +import { TICK_HEIGHT } from "components/events/utils"; import * as types from "lib/types"; function activityLineEqual( diff --git a/frontend/src/components/events/timeline/HoverLine.tsx b/frontend/src/components/events/timeline/HoverLine.tsx index 120f8dabb..d194a101e 100644 --- a/frontend/src/components/events/timeline/HoverLine.tsx +++ b/frontend/src/components/events/timeline/HoverLine.tsx @@ -3,7 +3,7 @@ import dayjs from "dayjs"; import DOMPurify from "dompurify"; import { memo, useEffect, useRef } from "react"; -import { getDateAtPosition } from "components/events/timeline/utils"; +import { getDateAtPosition } from "components/events/utils"; import { dateToTimestamp, getTimeFromDate } from "lib/helpers"; const useSetPosition = ( diff --git a/frontend/src/components/events/timeline/Item.tsx b/frontend/src/components/events/timeline/Item.tsx index 2b88e4339..9d869d95e 100644 --- a/frontend/src/components/events/timeline/Item.tsx +++ b/frontend/src/components/events/timeline/Item.tsx @@ -1,9 +1,9 @@ import { memo } from "react"; +import { SnapshotEvent } from "components/events/SnapshotEvent"; import { ActivityLine } from "components/events/timeline/ActivityLine"; -import { ObjectEvent } from "components/events/timeline/ObjectEvent"; import { TimeTick } from "components/events/timeline/TimeTick"; -import { TimelineItem } from "components/events/timeline/utils"; +import { TimelineItem } from "components/events/utils"; export const itemEqual = ( prevItem: Readonly, @@ -16,8 +16,7 @@ export const itemEqual = ( nextItem.item.timedEvent?.start_timestamp && prevItem.item.timedEvent?.end_timestamp === nextItem.item.timedEvent?.end_timestamp && - prevItem.item.snapshotEvent?.timestamp === - nextItem.item.snapshotEvent?.timestamp; + prevItem.item.snapshotEvents?.length === nextItem.item.snapshotEvents?.length; type ItemProps = { item: TimelineItem; @@ -34,10 +33,10 @@ export const Item = memo( variant={item.activityLineVariant} availableTimespan={!!item.availableTimespan} /> - {item.snapshotEvent ? ( - ) : null} diff --git a/frontend/src/components/events/timeline/ObjectEvent.tsx b/frontend/src/components/events/timeline/ObjectEvent.tsx deleted file mode 100644 index 2366bcad3..000000000 --- a/frontend/src/components/events/timeline/ObjectEvent.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import Image from "@jy95/material-ui-image"; -import DirectionsCarIcon from "@mui/icons-material/DirectionsCar"; -import ImageSearchIcon from "@mui/icons-material/ImageSearch"; -import PersonIcon from "@mui/icons-material/Person"; -import Box from "@mui/material/Box"; -import Tooltip from "@mui/material/Tooltip"; -import { useTheme } from "@mui/material/styles"; -// eslint-disable-next-line import/no-extraneous-dependencies -import { Instance } from "@popperjs/core"; -import { memo, useRef, useState } from "react"; - -import { - TICK_HEIGHT, - convertToPercentage, -} from "components/events/timeline/utils"; -import * as types from "lib/types"; - -const labelToIcon = (label: string) => { - switch (label) { - case "person": - return PersonIcon; - case "car" || "truck" || "vehicle": - return DirectionsCarIcon; - default: - return ImageSearchIcon; - } -}; - -const Divider = ({ boxRef }: { boxRef?: React.Ref }) => ( - ({ - height: "1px", - flexGrow: 1, - backgroundColor: theme.palette.divider, - })} - /> -); - -const DetectionDetails = ({ - objectEvent, - boxRef, -}: { - objectEvent: types.CameraObjectEvent; - boxRef?: React.Ref; -}) => { - const theme = useTheme(); - const Icon = labelToIcon(objectEvent.label); - return ( - - - - ); -}; - -const DetectionSnapshot = ({ - objectEvent, - scale, -}: { - objectEvent: types.CameraObjectEvent; - scale: number; -}) => { - const theme = useTheme(); - return ( - 1 ? "70%" : "35%", - margin: "auto", - marginLeft: "10px", - marginRight: "10px", - overflow: "hidden", - borderRadius: "5px", - border: `1px solid ${ - theme.palette.mode === "dark" - ? theme.palette.primary[900] - : theme.palette.primary[200] - }`, - transform: `translateY(calc(-50% + ${TICK_HEIGHT / 2}px))`, - transition: "width 0.15s ease-in-out, box-shadow 0.15s ease-in-out", - boxShadow: - scale > 1 - ? "0px 0px 10px 5px rgba(0,0,0,0.75)" - : "0px 0px 5px 0px rgba(0,0,0,0.75)", - }} - > - - - ); -}; - -type ObjectEventProps = { - objectEvent: types.CameraObjectEvent; -}; -export const ObjectEvent = memo(({ objectEvent }: ObjectEventProps) => { - const [scale, setScale] = useState(1); - const popperRef = useRef(null); - const tooltipAnchor = useRef(null); - const date = new Date(objectEvent.time); - - return ( - - {`Label: ${objectEvent.label}`} - {`Confidence: ${convertToPercentage( - objectEvent.confidence, - )}%`} - {`Timestamp: ${date.toLocaleString()}`} - - } - PopperProps={{ - popperRef, - anchorEl: tooltipAnchor.current, - }} - > - { - setScale(1.05); - }} - onMouseLeave={() => setScale(1)} - sx={{ - display: "flex", - alignItems: "center", - justifyContent: "center", - height: TICK_HEIGHT, - width: "100%", - }} - > - - - - - - - ); -}); diff --git a/frontend/src/components/events/timeline/ProgressLine.tsx b/frontend/src/components/events/timeline/ProgressLine.tsx index 196bc57ad..75b3eb3e6 100644 --- a/frontend/src/components/events/timeline/ProgressLine.tsx +++ b/frontend/src/components/events/timeline/ProgressLine.tsx @@ -3,7 +3,7 @@ import DOMPurify from "dompurify"; import Hls from "hls.js"; import { memo, useEffect, useRef } from "react"; -import { TICK_HEIGHT, getYPosition } from "components/events/timeline/utils"; +import { TICK_HEIGHT, getYPosition } from "components/events/utils"; import { dateToTimestamp, getTimeFromDate } from "lib/helpers"; const useTimeUpdate = ( diff --git a/frontend/src/components/events/timeline/Row.tsx b/frontend/src/components/events/timeline/Row.tsx index d97d2313f..3ce402486 100644 --- a/frontend/src/components/events/timeline/Row.tsx +++ b/frontend/src/components/events/timeline/Row.tsx @@ -2,7 +2,7 @@ import { VirtualItem } from "@tanstack/react-virtual"; import { memo, useState } from "react"; import { Item, itemEqual } from "components/events/timeline/Item"; -import { TimelineItem } from "components/events/timeline/utils"; +import { TimelineItem } from "components/events/utils"; const rowEqual = (prevItem: Readonly, nextItem: Readonly) => prevItem.virtualItem === nextItem.virtualItem && @@ -20,13 +20,13 @@ export const Row = memo(({ virtualItem, item }: RowProps): JSX.Element => {
{ - if (!item.snapshotEvent) { + if (!item.snapshotEvents) { return; } setHover(true); }} onMouseLeave={() => { - if (!item.snapshotEvent) { + if (!item.snapshotEvents) { return; } setHover(false); @@ -42,7 +42,7 @@ export const Row = memo(({ virtualItem, item }: RowProps): JSX.Element => { transform: `translateY(${virtualItem.start}px)`, transition: "transform 0.2s linear", zIndex: - item.snapshotEvent && hover ? 999 : item.snapshotEvent ? 998 : 1, + item.snapshotEvents && hover ? 999 : item.snapshotEvents ? 998 : 1, }} > diff --git a/frontend/src/components/events/timeline/TimeTick.tsx b/frontend/src/components/events/timeline/TimeTick.tsx index accee83c6..a5caae6a4 100644 --- a/frontend/src/components/events/timeline/TimeTick.tsx +++ b/frontend/src/components/events/timeline/TimeTick.tsx @@ -2,7 +2,7 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { memo } from "react"; -import { TICK_HEIGHT } from "components/events/timeline/utils"; +import { TICK_HEIGHT } from "components/events/utils"; import { getTimeFromDate, timestampToDate } from "lib/helpers"; type TimeTickProps = { diff --git a/frontend/src/components/events/timeline/TimelinePlayer.tsx b/frontend/src/components/events/timeline/TimelinePlayer.tsx index 47bf62d94..8eaf7852b 100644 --- a/frontend/src/components/events/timeline/TimelinePlayer.tsx +++ b/frontend/src/components/events/timeline/TimelinePlayer.tsx @@ -8,7 +8,7 @@ import { SCALE, calculateHeight, findFragmentByTimestamp, -} from "components/events/timeline/utils"; +} from "components/events/utils"; import { useAuthContext } from "context/AuthContext"; import { getToken } from "lib/tokens"; import * as types from "lib/types"; @@ -121,7 +121,7 @@ const initializePlayer = ( liveDurationInfinity: true, async xhrSetup(xhr, _url) { xhr.withCredentials = true; - if (auth) { + if (auth.enabled) { const token = await getToken(); if (token) { xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); diff --git a/frontend/src/components/events/timeline/TimelineTable.tsx b/frontend/src/components/events/timeline/TimelineTable.tsx index f3c63d4a9..7905a588a 100644 --- a/frontend/src/components/events/timeline/TimelineTable.tsx +++ b/frontend/src/components/events/timeline/TimelineTable.tsx @@ -14,7 +14,7 @@ import { calculateStart, getDateAtPosition, getTimelineItems, -} from "components/events/timeline/utils"; +} from "components/events/utils"; import { Loading } from "components/loading/Loading"; import { ViseronContext } from "context/ViseronContext"; import { useEvents } from "lib/api/events"; diff --git a/frontend/src/components/events/timeline/VirtualList.tsx b/frontend/src/components/events/timeline/VirtualList.tsx index b2c32df92..3faaaa339 100644 --- a/frontend/src/components/events/timeline/VirtualList.tsx +++ b/frontend/src/components/events/timeline/VirtualList.tsx @@ -8,7 +8,7 @@ import { calculateItemCount, calculateTimeFromIndex, getItem, -} from "components/events/timeline/utils"; +} from "components/events/utils"; type VirtualListProps = { parentRef: React.MutableRefObject; diff --git a/frontend/src/components/events/timeline/utils.tsx b/frontend/src/components/events/utils.tsx similarity index 69% rename from frontend/src/components/events/timeline/utils.tsx rename to frontend/src/components/events/utils.tsx index fd50ae71b..7dcdf5551 100644 --- a/frontend/src/components/events/timeline/utils.tsx +++ b/frontend/src/components/events/utils.tsx @@ -1,17 +1,19 @@ import dayjs, { Dayjs } from "dayjs"; import { Fragment } from "hls.js"; -import { dateToTimestamp } from "lib/helpers"; +import { BLANK_IMAGE, dateToTimestamp } from "lib/helpers"; import * as types from "lib/types"; export const TICK_HEIGHT = 8; export const SCALE = 60; export const EXTRA_TICKS = 10; export const COLUMN_HEIGHT = "99dvh"; +export const EVENT_ICON_HEIGHT = 20; + export const DEFAULT_ITEM: TimelineItem = { time: 0, timedEvent: null, - snapshotEvent: null, + snapshotEvents: null, availableTimespan: null, activityLineVariant: null, }; @@ -19,7 +21,7 @@ export const DEFAULT_ITEM: TimelineItem = { export type TimelineItem = { time: number; timedEvent: null | types.CameraMotionEvent | types.CameraRecordingEvent; - snapshotEvent: null | types.CameraObjectEvent; + snapshotEvents: null | types.CameraSnapshotEvents; availableTimespan: null | types.HlsAvailableTimespan; activityLineVariant: "first" | "middle" | "last" | "round" | null; }; @@ -155,6 +157,40 @@ export const createActivityLineItem = ( return timelineItems; }; +// For snapshot events, make sure adjacent events are grouped together +const addSnapshotEvent = ( + startRef: React.MutableRefObject, + timelineItems: TimelineItems, + cameraEvent: types.CameraSnapshotEvent, +) => { + // Find number of grouped ticks + const groupedTicks = Math.ceil(EVENT_ICON_HEIGHT / TICK_HEIGHT); + + // Check if previous ticks have snapshot events and group them + const index = calculateIndexFromTime(startRef, cameraEvent.timestamp); + const groupedSnapshotEvents: types.CameraSnapshotEvents = []; + for (let i = 0; i < groupedTicks; i++) { + const time = calculateTimeFromIndex(startRef, index - i); + if (time in timelineItems && timelineItems[time].snapshotEvents) { + groupedSnapshotEvents.push(...(timelineItems[time].snapshotEvents || [])); + timelineItems[time].snapshotEvents = null; + } + } + + // Add the (grouped) snapshot events to the timeline + const time = calculateTimeFromIndex(startRef, index); + timelineItems[time] = { + ...DEFAULT_ITEM, + ...timelineItems[time], + time, + snapshotEvents: [ + ...groupedSnapshotEvents, + ...(timelineItems[time].snapshotEvents || []), + cameraEvent, + ], + }; +}; + // Get the timeline items from the events and available timespans export const getTimelineItems = ( startRef: React.MutableRefObject, @@ -218,16 +254,17 @@ export const getTimelineItems = ( cameraEvent.type === "object", ) .forEach((cameraEvent) => { - const index = calculateIndexFromTime(startRef, cameraEvent.timestamp); - const time = calculateTimeFromIndex(startRef, index); - timelineItems[time] = { - ...DEFAULT_ITEM, - ...timelineItems[time], - time, - snapshotEvent: cameraEvent, - }; + addSnapshotEvent(startRef, timelineItems, cameraEvent); }); + eventsData + .filter( + (cameraEvent): cameraEvent is types.CameraFaceRecognitionEvent => + cameraEvent.type === "face_recognition", + ) + .forEach((cameraEvent) => { + addSnapshotEvent(startRef, timelineItems, cameraEvent); + }); return timelineItems; }; @@ -266,3 +303,81 @@ export const calculateHeight = ( cameraHeight: number, width: number, ): number => (width * cameraHeight) / cameraWidth; + +export const getSrc = (event: types.CameraEvent) => { + switch (event.type) { + case "recording": + return event.thumbnail_path; + case "object": + case "face_recognition": + return event.snapshot_path; + default: + return BLANK_IMAGE; + } +}; + +// Extract unique snapshot event types into a map +export const extractUniqueTypes = ( + snapshotEvents: types.CameraSnapshotEvents, +) => { + if (!snapshotEvents) { + return {}; + } + + const typeMap = new Map(); + + snapshotEvents.forEach((event) => { + const type = event.type; + if (!typeMap.has(type)) { + typeMap.set(type, []); + } + typeMap.get(type)!.push(event); + }); + + const result: { [key: string]: types.CameraSnapshotEvents } = {}; + typeMap.forEach((value, key) => { + result[key] = value; + }); + + return result; +}; + +// Extract unique labels for object snapshot events into a map +export const extractUniqueLabels = (objectEvents: types.CameraObjectEvents) => { + if (!objectEvents) { + return {}; + } + + const labelMap = new Map(); + + objectEvents.forEach((event) => { + let label; + switch (event.label) { + case "car": + case "truck": + case "vehicle": + label = "vehicle"; + break; + default: + label = event.label; + } + + if (!labelMap.has(label)) { + labelMap.set(label, []); + } + labelMap.get(label)!.push(event); + }); + + const result: { [key: string]: types.CameraObjectEvents } = {}; + labelMap.forEach((value, key) => { + result[key] = value; + }); + + return result; +}; + +export const filterEvents = (events: types.CameraEvent[]) => + events.filter( + (cameraEvent): cameraEvent is types.CameraSnapshotEvent => + cameraEvent.type === "object" || cameraEvent.type === "face_recognition", + ); diff --git a/frontend/src/components/videoplayer/VideoPlayerPlaceholder.tsx b/frontend/src/components/videoplayer/VideoPlayerPlaceholder.tsx index 7909e756e..77a14eaa9 100644 --- a/frontend/src/components/videoplayer/VideoPlayerPlaceholder.tsx +++ b/frontend/src/components/videoplayer/VideoPlayerPlaceholder.tsx @@ -6,6 +6,7 @@ import { useTheme } from "@mui/material/styles"; interface VideoPlayerPlaceholderProps { aspectRatio?: number; text?: string; + src?: string; } const blankImage = @@ -14,13 +15,14 @@ const blankImage = export default function VideoPlayerPlaceholder({ aspectRatio = 1920 / 1080, text, + src, }: VideoPlayerPlaceholderProps) { const theme = useTheme(); return ( ; }; -async function events({ - camera_identifier, - time_from, - time_to, -}: EventsVariables) { +type EventsVariablesWithDate = { + camera_identifier: string | null; + date: string; + configOptions?: UseQueryOptions; +}; +type EventsVariables = EventsVariablesWithTime | EventsVariablesWithDate; + +function events( + variables: EventsVariablesWithTime, +): Promise; +function events( + variables: EventsVariablesWithDate, +): Promise; +function events(variables: EventsVariables): Promise; + +async function events(variables: EventsVariables): Promise { + const { camera_identifier } = variables; + + const params: Record = {}; + if ("time_from" in variables && "time_to" in variables) { + params.time_from = variables.time_from; + params.time_to = variables.time_to; + } else if ("date" in variables) { + params.date = variables.date; + } + const response = await viseronAPI.get( `events/${camera_identifier}`, { - params: { - time_from, - time_to, - }, + params, }, ); return response.data; } -export const useEvents = ({ - camera_identifier, - time_from, - time_to, - configOptions, -}: EventsVariables) => - useQuery( - ["events", camera_identifier, time_from, time_to], - async () => events({ camera_identifier, time_from, time_to }), - configOptions, +// Overloaded function signatures for 'useEvents' +export function useEvents( + variables: EventsVariablesWithTime, +): UseQueryResult; +export function useEvents( + variables: EventsVariablesWithDate, +): UseQueryResult; + +export function useEvents(variables: EventsVariables) { + const queryKey = + "time_from" in variables && "time_to" in variables + ? [ + "events", + variables.camera_identifier, + variables.time_from, + variables.time_to, + ] + : ["events", variables.camera_identifier, variables.date]; + + return useQuery( + queryKey, + async () => events(variables), + variables.configOptions, ); +} diff --git a/frontend/src/lib/api/hls.ts b/frontend/src/lib/api/hls.ts index d299075c7..98dcf4423 100644 --- a/frontend/src/lib/api/hls.ts +++ b/frontend/src/lib/api/hls.ts @@ -3,7 +3,7 @@ import { UseQueryOptions, useQuery } from "@tanstack/react-query"; import { viseronAPI } from "lib/api/client"; import * as types from "lib/types"; -type HlsAvailableTimespansVariables = { +type HlsAvailableTimespansVariablesWithTime = { camera_identifier: string | null; time_from: number; time_to: number; @@ -12,31 +12,69 @@ type HlsAvailableTimespansVariables = { types.APIErrorResponse >; }; -async function availableTimespans({ - camera_identifier, - time_from, - time_to, -}: HlsAvailableTimespansVariables) { +type HlsAvailableTimespansVariablesWithDate = { + camera_identifier: string | null; + date: string; + configOptions?: UseQueryOptions< + types.HlsAvailableTimespans, + types.APIErrorResponse + >; +}; +type HlsAvailableTimespansVariables = + | HlsAvailableTimespansVariablesWithTime + | HlsAvailableTimespansVariablesWithDate; + +function availableTimespans( + variables: HlsAvailableTimespansVariablesWithTime, +): Promise; +function availableTimespans( + variables: HlsAvailableTimespansVariablesWithDate, +): Promise; +function availableTimespans( + variables: HlsAvailableTimespansVariables, +): Promise; + +async function availableTimespans(variables: HlsAvailableTimespansVariables) { + const { camera_identifier } = variables; + + const params: Record = {}; + if ("time_from" in variables && "time_to" in variables) { + params.time_from = variables.time_from; + params.time_to = variables.time_to; + } else if ("date" in variables) { + params.date = variables.date; + } + const response = await viseronAPI.get( `hls/${camera_identifier}/available_timespans`, { - params: { - time_from, - time_to, - }, + params, }, ); return response.data; } -export const useHlsAvailableTimespans = ({ - camera_identifier, - time_from, - time_to, - configOptions, -}: HlsAvailableTimespansVariables) => - useQuery( - ["hls", camera_identifier, "available_timespans", time_from, time_to], - async () => availableTimespans({ camera_identifier, time_from, time_to }), - configOptions, +export const useHlsAvailableTimespans = ( + variables: HlsAvailableTimespansVariables, +) => { + const queryKey = + "time_from" in variables && "time_to" in variables + ? [ + "hls", + variables.camera_identifier, + "available_timespans", + variables.time_from, + variables.time_to, + ] + : [ + "hls", + variables.camera_identifier, + "available_timespans", + variables.date, + ]; + return useQuery( + queryKey, + async () => availableTimespans(variables), + variables.configOptions, ); +}; diff --git a/frontend/src/lib/helpers.tsx b/frontend/src/lib/helpers.tsx index a57a4f02b..8afddc029 100644 --- a/frontend/src/lib/helpers.tsx +++ b/frontend/src/lib/helpers.tsx @@ -6,6 +6,9 @@ import * as types from "lib/types"; const VideoPlayer = lazy(() => import("components/videoplayer/VideoPlayer")); +export const BLANK_IMAGE = + "data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"; + export function sortObj(obj: Record): Record { return Object.keys(obj) .sort() @@ -61,7 +64,7 @@ export function getRecordingVideoJSOptions( export function getVideoElement( camera: types.Camera | types.FailedCamera, recording: types.Recording | null | undefined, - auth: boolean, + authEnabled: boolean, ) { if (!objHasValues(recording) || !recording) { return ( @@ -70,7 +73,7 @@ export function getVideoElement( } let authHeader: string | null = null; - if (auth) { + if (authEnabled) { authHeader = getAuthHeader(); } const videoJsOptions = getRecordingVideoJSOptions( diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 708d76285..8b1d48469 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -223,43 +223,64 @@ export interface EntityAttributes { } type CameraBaseEvent = { + id: number; created_at: string; }; -type CameraTimedEvent = CameraBaseEvent & { + +type CameraBaseTimedEvent = CameraBaseEvent & { start_time: string; start_timestamp: number; end_time: string | null; end_timestamp: number | null; }; -export type CameraMotionEvent = CameraTimedEvent & { +export type CameraMotionEvent = CameraBaseTimedEvent & { type: "motion"; }; -export type CameraRecordingEvent = CameraTimedEvent & { +export type CameraRecordingEvent = CameraBaseTimedEvent & { type: "recording"; + hls_url: string; + thumbnail_path: string; }; export type CameraTimedEvents = CameraMotionEvent | CameraRecordingEvent; -type CameraSnapshotEvent = CameraBaseEvent & { + +type CameraBaseSnapshotEvent = CameraBaseEvent & { time: string; timestamp: number; snapshot_path: string; }; -export type CameraObjectEvent = CameraSnapshotEvent & { +export type CameraObjectEvent = CameraBaseSnapshotEvent & { type: "object"; time: string; timestamp: number; label: string; confidence: number; }; +export type CameraFaceRecognitionEvent = CameraBaseSnapshotEvent & { + type: "face_recognition"; + data: { + name: string; + confidence: number; + [key: string]: any; + }; +}; export type CameraEvent = | CameraMotionEvent | CameraObjectEvent - | CameraRecordingEvent; + | CameraRecordingEvent + | CameraFaceRecognitionEvent; export type CameraEvents = { events: [CameraEvent]; }; +export type CameraSnapshotEvent = + | CameraObjectEvent + | CameraFaceRecognitionEvent; +export type CameraSnapshotEvents = Array; + +export type CameraObjectEvents = Array; + export interface Entity { entity_id: string; state: string; diff --git a/frontend/src/pages/Events.tsx b/frontend/src/pages/Events.tsx index cf567c3f8..c3c8dcb15 100644 --- a/frontend/src/pages/Events.tsx +++ b/frontend/src/pages/Events.tsx @@ -47,8 +47,9 @@ const Events = () => { }; }, [camerasQuery.data, failedCamerasQuery.data]); - const [selectedRecording, setSelectedRecording] = - useState(null); + const [selectedEvent, setSelectedEvent] = useState( + null, + ); const [date, setDate] = useState( searchParams.has("date") ? dayjs(searchParams.get("date") as string) @@ -66,7 +67,7 @@ const Events = () => { ) => { setSelectedCamera(camera); setRequestedTimestamp(null); - setSelectedRecording(null); + setSelectedEvent(null); }; useEffect(() => { @@ -119,8 +120,8 @@ const Events = () => { None: + # Add password to SensitiveInformationFilter. + # It is done in AbstractCamera but since we are calling Stream before + # super().__init__ we need to do it here as well + if config[CONFIG_PASSWORD]: + SensitiveInformationFilter.add_sensitive_string(config[CONFIG_PASSWORD]) + SensitiveInformationFilter.add_sensitive_string( + escape_string(config[CONFIG_PASSWORD]) + ) + self._poll_timer = utcnow().timestamp() self._frame_reader = None # Stream must be initialized before super().__init__ is called as it raises diff --git a/viseron/components/ffmpeg/stream.py b/viseron/components/ffmpeg/stream.py index 4ae5d0f60..75746c48a 100644 --- a/viseron/components/ffmpeg/stream.py +++ b/viseron/components/ffmpeg/stream.py @@ -24,6 +24,7 @@ ) from viseron.domains.camera.shared_frames import SharedFrame from viseron.exceptions import FFprobeError, FFprobeTimeout, StreamInformationError +from viseron.helpers import escape_string from viseron.helpers.logs import LogPipe, UnhelpfullLogFilter from viseron.watchdog.subprocess_watchdog import RestartablePopen @@ -194,7 +195,12 @@ def get_stream_url(self, stream_config: dict[str, Any]) -> str: """Return stream url.""" auth = "" if self._config[CONFIG_USERNAME] and self._config[CONFIG_PASSWORD]: - auth = f"{self._config[CONFIG_USERNAME]}:{self._config[CONFIG_PASSWORD]}@" + auth = ( + f"{self._config[CONFIG_USERNAME]}" + ":" + f"{escape_string(self._config[CONFIG_PASSWORD])}" + "@" + ) protocol = ( stream_config[CONFIG_PROTOCOL] diff --git a/viseron/components/mqtt/__init__.py b/viseron/components/mqtt/__init__.py index 1c9209721..195f7e701 100644 --- a/viseron/components/mqtt/__init__.py +++ b/viseron/components/mqtt/__init__.py @@ -167,7 +167,7 @@ def __init__(self, vis, config) -> None: self._vis = vis self._config = config - self._client = mqtt.Client(self._config[CONFIG_CLIENT_ID]) + self._client = mqtt.Client(client_id=self._config[CONFIG_CLIENT_ID]) self._publish_queue: Queue = Queue(maxsize=1000) self._subscriptions: dict[str, list[Callable]] = {} diff --git a/viseron/components/nvr/const.py b/viseron/components/nvr/const.py index 6d6e6b08f..d477f74ee 100644 --- a/viseron/components/nvr/const.py +++ b/viseron/components/nvr/const.py @@ -8,7 +8,8 @@ CAMERA: Final = "camera" OBJECT_DETECTOR: Final = "object_detector" MOTION_DETECTOR: Final = "motion_detector" - +NO_DETECTOR: Final = "no_detector" +NO_DETECTOR_FPS: Final = 1 # Data stream topic constants DATA_PROCESSED_FRAME_TOPIC = "{camera_identifier}/nvr/processed_frame" @@ -18,4 +19,7 @@ EVENT_OPERATION_STATE = "{camera_identifier}/nvr/operation_state" EVENT_SCAN_FRAMES = "{camera_identifier}/nvr/{scanner_name}/scan" +DATA_NO_DETECTOR_SCAN = "no_detector/{camera_identifier}/scan" +DATA_NO_DETECTOR_RESULT = "no_detector/{camera_identifier}/result" + DESC_COMPONENT = "NVR configuration." diff --git a/viseron/components/nvr/nvr.py b/viseron/components/nvr/nvr.py index ef932f4b6..d6259e492 100644 --- a/viseron/components/nvr/nvr.py +++ b/viseron/components/nvr/nvr.py @@ -33,10 +33,14 @@ from viseron.watchdog.thread_watchdog import RestartableThread from .const import ( + DATA_NO_DETECTOR_RESULT, + DATA_NO_DETECTOR_SCAN, DATA_PROCESSED_FRAME_TOPIC, EVENT_OPERATION_STATE, EVENT_SCAN_FRAMES, MOTION_DETECTOR, + NO_DETECTOR, + NO_DETECTOR_FPS, OBJECT_DETECTOR, ) from .sensor import OperationStateSensor @@ -75,13 +79,6 @@ def setup(vis: Viseron, config, identifier) -> bool: except DomainNotRegisteredError: motion_detector = False - if object_detector is False and motion_detector is False: - LOGGER.error( - f"Failed setup of domain nvr for camera {identifier}. " - "At least one object or motion detector has to be configured" - ) - return False - NVR(vis, config, identifier, object_detector, motion_detector) return True @@ -125,13 +122,13 @@ class FrameIntervalCalculator: def __init__( self, vis: Viseron, - camera_identifier, - name, - logger, - output_fps, - scan_fps, - topic_scan, - topic_result, + camera_identifier: str, + name: str, + logger: logging.Logger, + output_fps: int, + scan_fps: int, + topic_scan: str, + topic_result: str, ) -> None: self._vis = vis self._camera_identifier = camera_identifier @@ -298,6 +295,21 @@ def __init__( elif self._motion_detector: self._frame_scanners[MOTION_DETECTOR].scan = True + if not self._object_detector and not self._motion_detector: + self._logger.debug("Running without any detectors") + self._frame_scanners[NO_DETECTOR] = FrameIntervalCalculator( + vis, + self._camera.identifier, + NO_DETECTOR, + self._logger, + self._camera.output_fps, + NO_DETECTOR_FPS, + DATA_NO_DETECTOR_SCAN.format(camera_identifier=self._camera.identifier), + DATA_NO_DETECTOR_RESULT.format( + camera_identifier=self._camera.identifier + ), + ) + self._frame_queue: Queue[SharedFrame] = Queue(maxsize=100) self._data_stream.subscribe_data( self._camera.frame_bytes_topic, self._frame_queue diff --git a/viseron/components/storage/config.py b/viseron/components/storage/config.py index 63e15f091..0c3c3dc9f 100644 --- a/viseron/components/storage/config.py +++ b/viseron/components/storage/config.py @@ -7,6 +7,7 @@ from viseron.components.storage.const import ( COMPONENT, + CONFIG_CHECK_INTERVAL, CONFIG_CONTINUOUS, CONFIG_DAYS, CONFIG_EVENTS, @@ -24,8 +25,14 @@ CONFIG_PATH, CONFIG_POLL, CONFIG_RECORDER, + CONFIG_SECONDS, CONFIG_SNAPSHOTS, CONFIG_TIERS, + DEFAULT_CHECK_INTERVAL, + DEFAULT_CHECK_INTERVAL_DAYS, + DEFAULT_CHECK_INTERVAL_HOURS, + DEFAULT_CHECK_INTERVAL_MINUTES, + DEFAULT_CHECK_INTERVAL_SECONDS, DEFAULT_CONTINUOUS, DEFAULT_DAYS, DEFAULT_EVENTS, @@ -45,6 +52,11 @@ DEFAULT_RECORDER_TIERS, DEFAULT_SNAPSHOTS, DEFAULT_SNAPSHOTS_TIERS, + DESC_CHECK_INTERVAL, + DESC_CHECK_INTERVAL_DAYS, + DESC_CHECK_INTERVAL_HOURS, + DESC_CHECK_INTERVAL_MINUTES, + DESC_CHECK_INTERVAL_SECONDS, DESC_CONTINUOUS, DESC_DOMAIN_TIERS, DESC_EVENTS, @@ -102,7 +114,9 @@ def __call__(self, value: str) -> str: return value -def get_size_schema(age_type: Literal["min"] | Literal["max"]) -> vol.Schema: +def get_size_schema( + age_type: Literal["min"] | Literal["max"], +) -> dict[vol.Optional, Maybe]: """Get size schema.""" return { vol.Optional( @@ -118,7 +132,9 @@ def get_size_schema(age_type: Literal["min"] | Literal["max"]) -> vol.Schema: } -def get_age_schema(age_type: Literal["min"] | Literal["max"]) -> vol.Schema: +def get_age_schema( + age_type: Literal["min"] | Literal["max"], +) -> dict[vol.Optional, Maybe]: """Get age schema.""" return { vol.Optional( @@ -180,6 +196,35 @@ def get_age_schema(age_type: Literal["min"] | Literal["max"]) -> vol.Schema: default=DEFAULT_MOVE_ON_SHUTDOWN, description=DESC_MOVE_ON_SHUTDOWN, ): bool, + vol.Optional( + CONFIG_CHECK_INTERVAL, + default=DEFAULT_CHECK_INTERVAL, + description=DESC_CHECK_INTERVAL, + ): vol.All( + CoerceNoneToDict(), + { + vol.Optional( + CONFIG_DAYS, + default=DEFAULT_CHECK_INTERVAL_DAYS, + description=DESC_CHECK_INTERVAL_DAYS, + ): vol.All(int, vol.Range(min=0)), + vol.Optional( + CONFIG_HOURS, + default=DEFAULT_CHECK_INTERVAL_HOURS, + description=DESC_CHECK_INTERVAL_HOURS, + ): vol.All(int, vol.Range(min=0)), + vol.Optional( + CONFIG_MINUTES, + default=DEFAULT_CHECK_INTERVAL_MINUTES, + description=DESC_CHECK_INTERVAL_MINUTES, + ): vol.All(int, vol.Range(min=0)), + vol.Optional( + CONFIG_SECONDS, + default=DEFAULT_CHECK_INTERVAL_SECONDS, + description=DESC_CHECK_INTERVAL_SECONDS, + ): vol.All(int, vol.Range(min=0)), + }, + ), } ) @@ -199,6 +244,35 @@ def get_age_schema(age_type: Literal["min"] | Literal["max"]) -> vol.Schema: default=DEFAULT_MOVE_ON_SHUTDOWN, description=DESC_MOVE_ON_SHUTDOWN, ): bool, + vol.Optional( + CONFIG_CHECK_INTERVAL, + default=DEFAULT_CHECK_INTERVAL, + description=DESC_CHECK_INTERVAL, + ): vol.All( + CoerceNoneToDict(), + { + vol.Optional( + CONFIG_DAYS, + default=DEFAULT_CHECK_INTERVAL_DAYS, + description=DESC_CHECK_INTERVAL_DAYS, + ): vol.All(int, vol.Range(min=0)), + vol.Optional( + CONFIG_HOURS, + default=DEFAULT_CHECK_INTERVAL_HOURS, + description=DESC_CHECK_INTERVAL_HOURS, + ): vol.All(int, vol.Range(min=0)), + vol.Optional( + CONFIG_MINUTES, + default=DEFAULT_CHECK_INTERVAL_MINUTES, + description=DESC_CHECK_INTERVAL_MINUTES, + ): vol.All(int, vol.Range(min=0)), + vol.Optional( + CONFIG_SECONDS, + default=DEFAULT_CHECK_INTERVAL_SECONDS, + description=DESC_CHECK_INTERVAL_SECONDS, + ): vol.All(int, vol.Range(min=0)), + }, + ), vol.Optional( CONFIG_CONTINUOUS, default=DEFAULT_CONTINUOUS, diff --git a/viseron/components/storage/const.py b/viseron/components/storage/const.py index f91e89b67..285cf026a 100644 --- a/viseron/components/storage/const.py +++ b/viseron/components/storage/const.py @@ -7,14 +7,13 @@ DATABASE_URL = "postgresql://postgres@localhost/viseron" -MOVE_FILES_THROTTLE_SECONDS = 10 - # Storage configuration DESC_COMPONENT = "Storage configuration." DEFAULT_COMPONENT: dict[str, Any] = {} CONFIG_PATH: Final = "path" CONFIG_POLL: Final = "poll" CONFIG_MOVE_ON_SHUTDOWN: Final = "move_on_shutdown" +CONFIG_CHECK_INTERVAL: Final = "check_interval" CONFIG_MIN_SIZE: Final = "min_size" CONFIG_MAX_SIZE: Final = "max_size" CONFIG_MAX_AGE: Final = "max_age" @@ -24,6 +23,7 @@ CONFIG_DAYS: Final = "days" CONFIG_HOURS: Final = "hours" CONFIG_MINUTES: Final = "minutes" +CONFIG_SECONDS: Final = "seconds" CONFIG_RECORDER: Final = "recorder" CONFIG_CONTINUOUS: Final = "continuous" CONFIG_EVENTS: Final = "events" @@ -58,6 +58,11 @@ DEFAULT_POLL = False DEFAULT_MOVE_ON_SHUTDOWN = False +DEFAULT_CHECK_INTERVAL: Final = None +DEFAULT_CHECK_INTERVAL_DAYS: Final = 0 +DEFAULT_CHECK_INTERVAL_HOURS: Final = 0 +DEFAULT_CHECK_INTERVAL_MINUTES: Final = 1 +DEFAULT_CHECK_INTERVAL_SECONDS: Final = 0 DEFAULT_GB: Final = None DEFAULT_MB: Final = None DEFAULT_DAYS: Final = None @@ -124,6 +129,11 @@ "Move/delete files to the next tier when Viseron shuts down. " "Useful to not lose files when shutting down Viseron if using a RAM disk." ) +DESC_CHECK_INTERVAL = "How often to check for files to move to the next tier." +DESC_CHECK_INTERVAL_DAYS = "Days between checks for files to move/delete." +DESC_CHECK_INTERVAL_HOURS = "Hours between checks for files to move/delete." +DESC_CHECK_INTERVAL_MINUTES = "Minutes between checks for files to move/delete." +DESC_CHECK_INTERVAL_SECONDS = "Seconds between checks for files to move/delete." DESC_MIN_SIZE = "Minimum size of files to keep in this tier." DESC_MAX_SIZE = "Maximum size of files to keep in this tier." DESC_MIN_AGE = "Minimum age of files to keep in this tier." diff --git a/viseron/components/storage/tier_handler.py b/viseron/components/storage/tier_handler.py index 97ba28848..a64208c99 100644 --- a/viseron/components/storage/tier_handler.py +++ b/viseron/components/storage/tier_handler.py @@ -25,16 +25,20 @@ from viseron.components.storage.const import ( COMPONENT, + CONFIG_CHECK_INTERVAL, CONFIG_CONTINUOUS, + CONFIG_DAYS, CONFIG_EVENTS, + CONFIG_HOURS, CONFIG_MAX_AGE, CONFIG_MAX_SIZE, CONFIG_MIN_AGE, CONFIG_MIN_SIZE, + CONFIG_MINUTES, CONFIG_MOVE_ON_SHUTDOWN, CONFIG_PATH, CONFIG_POLL, - MOVE_FILES_THROTTLE_SECONDS, + CONFIG_SECONDS, ) from viseron.components.storage.models import Files, FilesMeta, Recordings from viseron.components.storage.queries import ( @@ -103,7 +107,10 @@ def __init__( self._event_thread.start() self._throttle_period = timedelta( - seconds=MOVE_FILES_THROTTLE_SECONDS, + days=tier[CONFIG_CHECK_INTERVAL].get(CONFIG_DAYS, 0), + hours=tier[CONFIG_CHECK_INTERVAL].get(CONFIG_HOURS, 0), + minutes=tier[CONFIG_CHECK_INTERVAL].get(CONFIG_MINUTES, 0), + seconds=tier[CONFIG_CHECK_INTERVAL].get(CONFIG_SECONDS, 0), ) self._time_of_last_call = utcnow() self._check_tier_lock = Lock() @@ -169,10 +176,9 @@ def check_tier(self) -> None: now = utcnow() with self._check_tier_lock: time_since_last_call = now - self._time_of_last_call - if time_since_last_call > self._throttle_period: - self._time_of_last_call = now - else: + if time_since_last_call < self._throttle_period: return + self._check_tier(self._storage.get_session) self._time_of_last_call = now diff --git a/viseron/components/webserver/api/v1/events.py b/viseron/components/webserver/api/v1/events.py index 1906d8a50..540c2008b 100644 --- a/viseron/components/webserver/api/v1/events.py +++ b/viseron/components/webserver/api/v1/events.py @@ -3,6 +3,7 @@ import datetime import logging +import time from collections.abc import Callable from http import HTTPStatus from typing import TYPE_CHECKING @@ -10,9 +11,15 @@ import voluptuous as vol from sqlalchemy import select -from viseron.components.storage.models import Motion, Objects, Recordings +from viseron.components.storage.models import ( + Motion, + Objects, + PostProcessorResults, + Recordings, +) from viseron.components.webserver.api.handlers import BaseAPIHandler from viseron.domains.camera import FailedCamera +from viseron.domains.face_recognition import DOMAIN as FACE_RECOGNITION_DOMAIN if TYPE_CHECKING: from sqlalchemy.orm import Session @@ -31,10 +38,15 @@ class EventsAPIHandler(BaseAPIHandler): "supported_methods": ["GET"], "method": "get_events", "request_arguments_schema": vol.Schema( - { - vol.Required("time_from"): vol.Coerce(int), - vol.Required("time_to"): vol.Coerce(int), - }, + vol.Any( + { + vol.Required("time_from"): vol.Coerce(int), + vol.Required("time_to"): vol.Coerce(int), + }, + { + vol.Required("date"): str, + }, + ) ), }, ] @@ -45,7 +57,7 @@ def _motion_events( camera: AbstractCamera | FailedCamera, time_from: int, time_to: int, - ): + ) -> list: """Select motion events from database.""" time_from_datetime = datetime.datetime.fromtimestamp(time_from) time_to_datetime = datetime.datetime.fromtimestamp(time_to) @@ -63,6 +75,7 @@ def _motion_events( motion_events.append( { "type": "motion", + "id": event.id, "start_time": event.start_time, "start_timestamp": event.start_time.timestamp(), "end_time": event.end_time, @@ -97,6 +110,7 @@ def _object_event( object_events.append( { "type": "object", + "id": event.id, "time": event.created_at, "timestamp": event.created_at.timestamp(), "label": event.label, @@ -113,7 +127,7 @@ def _recording_events( camera: AbstractCamera | FailedCamera, time_from: int, time_to: int, - ): + ) -> list: """Select recording events from database.""" time_from_datetime = datetime.datetime.fromtimestamp(time_from) time_to_datetime = datetime.datetime.fromtimestamp(time_to) @@ -131,21 +145,65 @@ def _recording_events( recording_events.append( { "type": "recording", + "id": event.id, "start_time": event.start_time, "start_timestamp": event.start_time.timestamp(), "end_time": event.end_time, "end_timestamp": event.end_time.timestamp() if event.end_time else None, + "hls_url": ( + "/api/v1/hls/" + f"{event.camera_identifier}/{event.id}/index.m3u8" + ), + "thumbnail_path": f"/files{event.thumbnail_path}", "created_at": event.created_at, } ) return recording_events + def _post_processor_events( + self, + get_session: Callable[[], Session], + camera: AbstractCamera | FailedCamera, + time_from: int, + time_to: int, + ) -> list: + """Select post processor events from database.""" + time_from_datetime = datetime.datetime.fromtimestamp(time_from) + time_to_datetime = datetime.datetime.fromtimestamp(time_to) + with get_session() as session: + stmt = ( + select(PostProcessorResults) + .where(PostProcessorResults.camera_identifier == camera.identifier) + .where(PostProcessorResults.domain.in_([FACE_RECOGNITION_DOMAIN])) + .where( + PostProcessorResults.created_at.between( + time_from_datetime, time_to_datetime + ) + ) + ).order_by(PostProcessorResults.created_at.desc()) + post_processor_results = session.execute(stmt).scalars().all() + post_processor_events = [] + if post_processor_results: + for event in post_processor_results: + post_processor_events.append( + { + "type": event.domain, + "id": event.id, + "time": event.created_at, + "timestamp": event.created_at.timestamp(), + "snapshot_path": f"/files{event.snapshot_path}", + "data": event.data, + "created_at": event.created_at, + } + ) + return post_processor_events + async def get_events( self, camera_identifier: str, - ): + ) -> None: """Get events.""" camera = self._get_camera(camera_identifier, failed=True) @@ -156,30 +214,48 @@ async def get_events( ) return + # Get start of day in utc + if "date" in self.request_arguments: + time_from = ( + datetime.datetime.strptime(self.request_arguments["date"], "%Y-%m-%d") + - datetime.timedelta(seconds=time.localtime().tm_gmtoff) + ).timestamp() + time_to = time_from + 86400 + else: + time_from = self.request_arguments["time_from"] + time_to = self.request_arguments["time_to"] + motion_events = await self.run_in_executor( self._motion_events, self._get_session, camera, - self.request_arguments["time_from"], - self.request_arguments["time_to"], + time_from, + time_to, ) recording_events = await self.run_in_executor( self._recording_events, self._get_session, camera, - self.request_arguments["time_from"], - self.request_arguments["time_to"], + time_from, + time_to, ) object_events = await self.run_in_executor( self._object_event, self._get_session, camera, - self.request_arguments["time_from"], - self.request_arguments["time_to"], + time_from, + time_to, + ) + post_processor_events = await self.run_in_executor( + self._post_processor_events, + self._get_session, + camera, + time_from, + time_to, ) sorted_events = sorted( - motion_events + recording_events + object_events, + motion_events + recording_events + object_events + post_processor_events, key=lambda k: k["created_at"], reverse=True, ) diff --git a/viseron/components/webserver/api/v1/hls.py b/viseron/components/webserver/api/v1/hls.py index 678251829..43a40e9d4 100644 --- a/viseron/components/webserver/api/v1/hls.py +++ b/viseron/components/webserver/api/v1/hls.py @@ -4,6 +4,7 @@ import datetime import logging import os +import time from collections.abc import Callable from dataclasses import dataclass from http import HTTPStatus @@ -91,10 +92,17 @@ class HlsAPIHandler(BaseAPIHandler): "supported_methods": ["GET"], "method": "get_available_timespans", "request_arguments_schema": vol.Schema( - { - vol.Required("time_from"): vol.Coerce(int), - vol.Optional("time_to", default=None): vol.Maybe(vol.Coerce(int)), - } + vol.Any( + { + vol.Required("time_from"): vol.Coerce(int), + vol.Optional("time_to", default=None): vol.Maybe( + vol.Coerce(int) + ), + }, + { + vol.Required("date"): str, + }, + ) ), }, ] @@ -175,12 +183,23 @@ async def get_available_timespans( ) return + # Get start of day in utc + if "date" in self.request_arguments: + time_from = ( + datetime.datetime.strptime(self.request_arguments["date"], "%Y-%m-%d") + - datetime.timedelta(seconds=time.localtime().tm_gmtoff) + ).timestamp() + time_to = time_from + 86400 + else: + time_from = self.request_arguments["time_from"] + time_to = self.request_arguments["time_to"] + timespans = await self.run_in_executor( _get_available_timespans, self._get_session, camera, - self.request_arguments["time_from"], - self.request_arguments["time_to"], + time_from, + time_to, ) self.response_success(response={"timespans": timespans}) @@ -220,7 +239,7 @@ def _get_available_timespans( timespans.append( {"start": int(start), "end": int(end), "duration": int(end - start)} ) - start = fragment.creation_time.timestamp() + start = None end = None else: end = fragment.creation_time.timestamp() + fragment.duration diff --git a/viseron/components/webserver/request_handler.py b/viseron/components/webserver/request_handler.py index 8f8a8e8e5..6bad5b417 100644 --- a/viseron/components/webserver/request_handler.py +++ b/viseron/components/webserver/request_handler.py @@ -6,7 +6,7 @@ from collections.abc import Callable from datetime import timedelta from http import HTTPStatus -from typing import TYPE_CHECKING, Literal, TypeVar, overload +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload import tornado.web from sqlalchemy.orm import Session @@ -145,7 +145,7 @@ def set_cookies( secure=bool(self.request.protocol == "https"), ) - def clear_all_cookies(self, path: str = "/", domain: str | None = None) -> None: + def clear_all_cookies(self, **kwargs: Any) -> None: """Overridden clear_all_cookies. Clears all cookies except for the XSRF cookie. @@ -153,7 +153,7 @@ def clear_all_cookies(self, path: str = "/", domain: str | None = None) -> None: for name in self.request.cookies: if name == "_xsrf": continue - self.clear_cookie(name, path=path, domain=domain) + self.clear_cookie(name, *kwargs) def validate_access_token( self, access_token: str, check_refresh_token: bool = True diff --git a/viseron/components/webserver/websocket_api/__init__.py b/viseron/components/webserver/websocket_api/__init__.py index 6c5b180cc..cb05f1f44 100644 --- a/viseron/components/webserver/websocket_api/__init__.py +++ b/viseron/components/webserver/websocket_api/__init__.py @@ -164,31 +164,31 @@ async def handle_message(self, message) -> None: try: handler(self, schema(message)) except Exception as err: # pylint: disable=broad-except - await self.handle_exception(command_id, err) + await self.handle_exception(command_id, message, err) self._last_id = command_id - async def handle_exception(self, command_id, err: Exception) -> None: + async def handle_exception(self, command_id, message, err: Exception) -> None: """Handle an exception.""" log_handler = LOGGER.error if isinstance(err, vol.Invalid): code = WS_ERROR_INVALID_FORMAT - message = humanize_error(err.message, err) + err_msg = humanize_error(message, err) elif isinstance(err, Unauthorized): code = WS_ERROR_UNAUTHORIZED - message = "Unauthorized." + err_msg = "Unauthorized." else: # Log unknown errors as exceptions log_handler = LOGGER.exception code = WS_ERROR_UNKNOWN_ERROR - message = "Unknown error" + err_msg = "Unknown error" - log_handler("Error handling message. Code: %s, message: %s", code, message) + log_handler("Error handling message. Code: %s, message: %s", code, err_msg) await self.async_send_message( error_message( command_id, code, - message, + err_msg, ) ) diff --git a/viseron/components/webserver/websocket_api/commands.py b/viseron/components/webserver/websocket_api/commands.py index 303dac578..117235ab8 100644 --- a/viseron/components/webserver/websocket_api/commands.py +++ b/viseron/components/webserver/websocket_api/commands.py @@ -43,9 +43,7 @@ LOGGER = logging.getLogger(__name__) -def websocket_command( - schema: vol.Schema, -) -> Callable: +def websocket_command(schema: dict[Any, Any]) -> Callable: """Websocket command decorator.""" command = schema["type"] diff --git a/viseron/domains/camera/__init__.py b/viseron/domains/camera/__init__.py index d4b9d2bac..e4c792d62 100644 --- a/viseron/domains/camera/__init__.py +++ b/viseron/domains/camera/__init__.py @@ -44,9 +44,11 @@ from viseron.helpers import ( calculate_absolute_coords, create_directory, + escape_string, utcnow, zoom_boundingbox, ) +from viseron.helpers.logs import SensitiveInformationFilter from viseron.helpers.validators import CoerceNoneToDict, Deprecated, Maybe, Slug from .const import ( @@ -423,6 +425,13 @@ def __init__(self, vis: Viseron, component: str, config, identifier: str) -> Non ) self.fragmenter: Fragmenter = Fragmenter(vis, self) + if self.config[CONFIG_PASSWORD]: + SensitiveInformationFilter.add_sensitive_string( + self.config[CONFIG_PASSWORD] + ) + SensitiveInformationFilter.add_sensitive_string( + escape_string(self._config[CONFIG_PASSWORD]) + ) def as_dict(self) -> dict[str, Any]: """Return camera information as dict.""" @@ -441,7 +450,19 @@ def generate_token(self): def update_token(self) -> None: """Update access token.""" - self.access_tokens.append(self.generate_token()) + old_access_token = None + if len(self.access_tokens) == 2: + old_access_token = self.access_tokens[0] + + new_access_token = self.generate_token() + SensitiveInformationFilter.add_sensitive_string(new_access_token) + + self.access_tokens.append(new_access_token) + + if old_access_token: + SensitiveInformationFilter.remove_sensitive_string( + old_access_token, + ) self._access_token_entity.set_state() def calculate_output_fps(self, scanners: list[FrameIntervalCalculator]) -> None: diff --git a/viseron/domains/object_detector/__init__.py b/viseron/domains/object_detector/__init__.py index f21ad3482..7d39a3f7f 100644 --- a/viseron/domains/object_detector/__init__.py +++ b/viseron/domains/object_detector/__init__.py @@ -117,58 +117,60 @@ def ensure_min_max(label: dict) -> dict: LABEL_SCHEMA = vol.Schema( - { - vol.Required( - CONFIG_LABEL_LABEL, - description=DESC_LABEL_LABEL, - ): str, - vol.Optional( - CONFIG_LABEL_CONFIDENCE, - default=DEFAULT_LABEL_CONFIDENCE, - description=DESC_LABEL_CONFIDENCE, - ): FLOAT_MIN_ZERO_MAX_ONE, - vol.Optional( - CONFIG_LABEL_HEIGHT_MIN, - default=DEFAULT_LABEL_HEIGHT_MIN, - description=DESC_LABEL_HEIGHT_MIN, - ): FLOAT_MIN_ZERO_MAX_ONE, - vol.Optional( - CONFIG_LABEL_HEIGHT_MAX, - default=DEFAULT_LABEL_HEIGHT_MAX, - description=DESC_LABEL_HEIGHT_MAX, - ): FLOAT_MIN_ZERO_MAX_ONE, - vol.Optional( - CONFIG_LABEL_WIDTH_MIN, - default=DEFAULT_LABEL_WIDTH_MIN, - description=DESC_LABEL_WIDTH_MIN, - ): FLOAT_MIN_ZERO_MAX_ONE, - vol.Optional( - CONFIG_LABEL_WIDTH_MAX, - default=DEFAULT_LABEL_WIDTH_MAX, - description=DESC_LABEL_WIDTH_MAX, - ): FLOAT_MIN_ZERO_MAX_ONE, - vol.Optional( - CONFIG_LABEL_TRIGGER_RECORDER, - default=DEFAULT_LABEL_TRIGGER_RECORDER, - description=DESC_LABEL_TRIGGER_RECORDER, - ): bool, - vol.Optional( - CONFIG_LABEL_STORE, - default=DEFAULT_LABEL_STORE, - description=DESC_LABEL_STORE, - ): bool, - vol.Optional( - CONFIG_LABEL_STORE_INTERVAL, - default=DEFAULT_LABEL_STORE_INTERVAL, - description=DESC_LABEL_STORE_INTERVAL, - ): int, - vol.Optional( - CONFIG_LABEL_REQUIRE_MOTION, - default=DEFAULT_LABEL_REQUIRE_MOTION, - description=DESC_LABEL_REQUIRE_MOTION, - ): bool, - }, - ensure_min_max, + vol.All( + { + vol.Required( + CONFIG_LABEL_LABEL, + description=DESC_LABEL_LABEL, + ): str, + vol.Optional( + CONFIG_LABEL_CONFIDENCE, + default=DEFAULT_LABEL_CONFIDENCE, + description=DESC_LABEL_CONFIDENCE, + ): FLOAT_MIN_ZERO_MAX_ONE, + vol.Optional( + CONFIG_LABEL_HEIGHT_MIN, + default=DEFAULT_LABEL_HEIGHT_MIN, + description=DESC_LABEL_HEIGHT_MIN, + ): FLOAT_MIN_ZERO_MAX_ONE, + vol.Optional( + CONFIG_LABEL_HEIGHT_MAX, + default=DEFAULT_LABEL_HEIGHT_MAX, + description=DESC_LABEL_HEIGHT_MAX, + ): FLOAT_MIN_ZERO_MAX_ONE, + vol.Optional( + CONFIG_LABEL_WIDTH_MIN, + default=DEFAULT_LABEL_WIDTH_MIN, + description=DESC_LABEL_WIDTH_MIN, + ): FLOAT_MIN_ZERO_MAX_ONE, + vol.Optional( + CONFIG_LABEL_WIDTH_MAX, + default=DEFAULT_LABEL_WIDTH_MAX, + description=DESC_LABEL_WIDTH_MAX, + ): FLOAT_MIN_ZERO_MAX_ONE, + vol.Optional( + CONFIG_LABEL_TRIGGER_RECORDER, + default=DEFAULT_LABEL_TRIGGER_RECORDER, + description=DESC_LABEL_TRIGGER_RECORDER, + ): bool, + vol.Optional( + CONFIG_LABEL_STORE, + default=DEFAULT_LABEL_STORE, + description=DESC_LABEL_STORE, + ): bool, + vol.Optional( + CONFIG_LABEL_STORE_INTERVAL, + default=DEFAULT_LABEL_STORE_INTERVAL, + description=DESC_LABEL_STORE_INTERVAL, + ): int, + vol.Optional( + CONFIG_LABEL_REQUIRE_MOTION, + default=DEFAULT_LABEL_REQUIRE_MOTION, + description=DESC_LABEL_REQUIRE_MOTION, + ): bool, + }, + ensure_min_max, + ) ) ZONE_SCHEMA = vol.Schema( diff --git a/viseron/helpers/__init__.py b/viseron/helpers/__init__.py index 614402d08..d201e8423 100644 --- a/viseron/helpers/__init__.py +++ b/viseron/helpers/__init__.py @@ -9,6 +9,7 @@ import os import socket import tracemalloc +import urllib.parse from queue import Full, Queue from typing import TYPE_CHECKING, Any @@ -482,6 +483,11 @@ def get_free_port(port=1024, max_port=65535) -> int: raise OSError("no free ports") +def escape_string(string: str) -> str: + """Escape special characters in a string.""" + return urllib.parse.quote(string, safe="") + + def memory_usage_profiler(logger, key_type="lineno", limit=5) -> None: """Print a table with the lines that are using the most memory.""" snapshot = tracemalloc.take_snapshot() diff --git a/viseron/helpers/logs.py b/viseron/helpers/logs.py index 9c9aff6af..2aa234a4b 100644 --- a/viseron/helpers/logs.py +++ b/viseron/helpers/logs.py @@ -45,6 +45,20 @@ def filter(self, record: logging.LogRecord) -> bool: class SensitiveInformationFilter(logging.Filter): """Redacts sensitive information from logs.""" + sensitive_strings: list[str] = [] + + @classmethod + def add_sensitive_string(cls, sensitive_string: str) -> None: + """Add a sensitive string to the list of strings to redact.""" + if sensitive_string not in cls.sensitive_strings: + cls.sensitive_strings.append(sensitive_string) + + @classmethod + def remove_sensitive_string(cls, sensitive_string: str) -> None: + """Remove a sensitive string from the list of strings to redact.""" + if sensitive_string in cls.sensitive_strings: + cls.sensitive_strings.remove(sensitive_string) + def filter(self, record: logging.LogRecord) -> bool: """Filter log record.""" if isinstance(record.msg, str): @@ -62,6 +76,8 @@ def filter(self, record: logging.LogRecord) -> bool: record.msg, flags=re.IGNORECASE | re.MULTILINE, ) + for sensitive_string in self.sensitive_strings: + record.msg = record.msg.replace(sensitive_string, "*****") return True