diff --git a/frigate/http.py b/frigate/http.py index 836566296..923616380 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -638,122 +638,100 @@ def latest_frame(camera_name): return "Camera named {} not found".format(camera_name), 404 +# return hourly summary for recordings of camera +@bp.route("//recordings/summary") +def recordings_summary(camera_name): + recording_groups = ( + Recordings.select( + fn.strftime( + "%Y-%m-%d %H", + fn.datetime(Recordings.start_time, "unixepoch", "localtime"), + ).alias("hour"), + fn.SUM(Recordings.duration).alias("duration"), + fn.SUM(Recordings.motion).alias("motion"), + fn.SUM(Recordings.objects).alias("objects"), + ) + .where(Recordings.camera == camera_name) + .group_by( + fn.strftime( + "%Y-%m-%d %H", + fn.datetime(Recordings.start_time, "unixepoch", "localtime"), + ) + ) + .order_by( + fn.strftime( + "%Y-%m-%d H", + fn.datetime(Recordings.start_time, "unixepoch", "localtime"), + ).desc() + ) + ) + + event_groups = ( + Event.select( + fn.strftime( + "%Y-%m-%d %H", fn.datetime(Event.start_time, "unixepoch", "localtime") + ).alias("hour"), + fn.COUNT(Event.id).alias("count"), + ) + .where(Event.camera == camera_name, Event.has_clip) + .group_by( + fn.strftime( + "%Y-%m-%d %H", fn.datetime(Event.start_time, "unixepoch", "localtime") + ), + ) + .objects() + ) + + event_map = {g.hour: g.count for g in event_groups} + + days = {} + + for recording_group in recording_groups.objects(): + parts = recording_group.hour.split() + hour = parts[1] + day = parts[0] + events_count = event_map.get(recording_group.hour, 0) + hour_data = { + "hour": hour, + "events": events_count, + "motion": recording_group.motion, + "objects": recording_group.objects, + "duration": round(recording_group.duration), + } + if day not in days: + days[day] = {"events": events_count, "hours": [hour_data], "day": day} + else: + days[day]["events"] += events_count + days[day]["hours"].append(hour_data) + + return jsonify(list(days.values())) + + +# return hour of recordings data for camera @bp.route("//recordings") def recordings(camera_name): - dates = OrderedDict() + after = request.args.get( + "after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp() + ) + before = request.args.get("before", type=float, default=datetime.now().timestamp()) - # Retrieve all recordings for this camera recordings = ( - Recordings.select() - .where(Recordings.camera == camera_name) - .order_by(Recordings.start_time.asc()) - ) - - last_end = 0 - recording: Recordings - for recording in recordings: - date = datetime.fromtimestamp(recording.start_time) - key = date.strftime("%Y-%m-%d") - hour = date.strftime("%H") - - # Create Day Record - if key not in dates: - dates[key] = OrderedDict() - - # Create Hour Record - if hour not in dates[key]: - dates[key][hour] = {"delay": {}, "events": []} - - # Check for delay - the_hour = datetime.strptime(f"{key} {hour}", "%Y-%m-%d %H").timestamp() - # diff current recording start time and the greater of the previous end time or top of the hour - diff = recording.start_time - max(last_end, the_hour) - # Determine seconds into recording - seconds = 0 - if datetime.fromtimestamp(last_end).strftime("%H") == hour: - seconds = int(last_end - the_hour) - # Determine the delay - delay = min(int(diff), 3600 - seconds) - if delay > 1: - # Add an offset for any delay greater than a second - dates[key][hour]["delay"][seconds] = delay - - last_end = recording.end_time - - # Packing intervals to return all events with same label and overlapping times as one row. - # See: https://blogs.solidq.com/en/sqlserver/packing-intervals/ - events = Event.raw( - """WITH C1 AS - ( - SELECT id, label, camera, top_score, start_time AS ts, +1 AS type, 1 AS sub - FROM event - WHERE camera = ? - UNION ALL - SELECT id, label, camera, top_score, end_time + 15 AS ts, -1 AS type, 0 AS sub - FROM event - WHERE camera = ? - ), - C2 AS - ( - SELECT C1.*, - SUM(type) OVER(PARTITION BY label ORDER BY ts, type DESC - ROWS BETWEEN UNBOUNDED PRECEDING - AND CURRENT ROW) - sub AS cnt - FROM C1 - ), - C3 AS - ( - SELECT id, label, camera, top_score, ts, - (ROW_NUMBER() OVER(PARTITION BY label ORDER BY ts) - 1) / 2 + 1 - AS grpnum - FROM C2 - WHERE cnt = 0 + Recordings.select( + Recordings.id, + Recordings.start_time, + Recordings.end_time, + Recordings.motion, + Recordings.objects, ) - SELECT id, label, camera, top_score, start_time, end_time - FROM event - WHERE camera = ? AND end_time IS NULL - UNION ALL - SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time - FROM C3 - GROUP BY label, grpnum - ORDER BY start_time;""", - camera_name, - camera_name, - camera_name, + .where( + Recordings.camera == camera_name, + Recordings.end_time >= after, + Recordings.start_time <= before, + ) + .order_by(Recordings.start_time) ) - event: Event - for event in events: - date = datetime.fromtimestamp(event.start_time) - key = date.strftime("%Y-%m-%d") - hour = date.strftime("%H") - if key in dates and hour in dates[key]: - dates[key][hour]["events"].append( - model_to_dict( - event, - exclude=[ - Event.false_positive, - Event.zones, - Event.thumbnail, - Event.has_clip, - Event.has_snapshot, - ], - ) - ) - - return jsonify( - [ - { - "date": date, - "events": sum([len(value["events"]) for value in hours.values()]), - "recordings": [ - {"hour": hour, "delay": value["delay"], "events": value["events"]} - for hour, value in hours.items() - ], - } - for date, hours in dates.items() - ] - ) + return jsonify([e for e in recordings.dicts()]) @bp.route("//start//end//clip.mp4") diff --git a/web/src/App.jsx b/web/src/App.jsx index 238b0f757..f095aa025 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -31,7 +31,10 @@ export default function App() { - + diff --git a/web/src/api/index.jsx b/web/src/api/index.jsx index 0ed99afd2..6e1db4388 100644 --- a/web/src/api/index.jsx +++ b/web/src/api/index.jsx @@ -10,7 +10,13 @@ export function ApiProvider({ children, options }) { return ( axios.get(path).then((res) => res.data), + fetcher: (arg) => { + if (typeof arg === 'string') { + return axios.get(arg).then((res) => res.data); + } + const [path, params] = arg; + return axios.get(path, { params }).then((res) => res.data); + }, ...options, }} > diff --git a/web/src/components/RecordingPlaylist.jsx b/web/src/components/RecordingPlaylist.jsx index 96182bb42..eda3b7164 100644 --- a/web/src/components/RecordingPlaylist.jsx +++ b/web/src/components/RecordingPlaylist.jsx @@ -1,59 +1,39 @@ import { h } from 'preact'; -import { useState } from 'preact/hooks'; +import { useState, useMemo } from 'preact/hooks'; import { - differenceInSeconds, + getUnixTime, fromUnixTime, format, parseISO, - startOfHour, - differenceInMinutes, - differenceInHours + intervalToDuration, + formatDuration, + endOfDay, + startOfDay, + isSameDay, } from 'date-fns'; import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropup from '../icons/ArrowDropup'; import Link from '../components/Link'; +import ActivityIndicator from '../components/ActivityIndicator'; import Menu from '../icons/Menu'; import MenuOpen from '../icons/MenuOpen'; import { useApiHost } from '../api'; +import useSWR from 'swr'; export default function RecordingPlaylist({ camera, recordings, selectedDate }) { const [active, setActive] = useState(true); const toggle = () => setActive(!active); const result = []; - for (const recording of recordings.slice().reverse()) { - const date = parseISO(recording.date); + for (const recording of recordings) { + const date = parseISO(recording.day); result.push( - {recording.recordings - .slice() - .reverse() - .map((item, i) => ( -
-
-
- - {item.hour}:00 - -
-
{item.events.length} Events
-
- {item.events - .slice() - .reverse() - .map((event) => ( - - ))} -
- ))} +
); } @@ -79,6 +59,71 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate }) ); } +export function DayOfEvents({ camera, day, hours }) { + const date = parseISO(day); + const { data: events } = useSWR([ + `events`, + { + before: getUnixTime(endOfDay(date)), + after: getUnixTime(startOfDay(date)), + camera, + has_clip: '1', + include_thumbnails: 0, + limit: 5000, + }, + ]); + + // maps all the events under the keys for the hour by hour recordings view + const eventMap = useMemo(() => { + const eventMap = {}; + for (const hour of hours) { + eventMap[`${day}-${hour.hour}`] = []; + } + + if (!events) { + return eventMap; + } + + for (const event of events) { + const key = format(fromUnixTime(event.start_time), 'yyyy-MM-dd-HH'); + // if the hour of recordings is missing for the event start time, skip it + if (key in eventMap) { + eventMap[key].push(event); + } + } + + return eventMap; + }, [events, day, hours]); + + if (!events) { + return ; + } + + return ( + <> + {hours.map((hour, i) => ( +
+
+
+ + {hour.hour}:00 + +
+
{hour.events} Events
+
+ {eventMap[`${day}-${hour.hour}`].map((event) => ( + + ))} +
+ ))} + + ); +} + export function ExpandableList({ title, events = 0, children, selected = false }) { const [active, setActive] = useState(selected); const toggle = () => setActive(!active); @@ -89,35 +134,26 @@ export function ExpandableList({ title, events = 0, children, selected = false }
{events} Events
{active ? : }
-
{children}
+ {/* Only render the child when expanded to lazy load events for the day */} + {active &&
{children}
} ); } -export function EventCard({ camera, event, delay }) { +export function EventCard({ camera, event }) { const apiHost = useApiHost(); const start = fromUnixTime(event.start_time); + const end = fromUnixTime(event.end_time); let duration = 'In Progress'; if (event.end_time) { - const end = fromUnixTime(event.end_time); - const hours = differenceInHours(end, start); - const minutes = differenceInMinutes(end, start) - hours * 60; - const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; - duration = ''; - if (hours) duration += `${hours}h `; - if (minutes) duration += `${minutes}m `; - duration += `${seconds}s`; + duration = formatDuration(intervalToDuration({ start, end })); } - const position = differenceInSeconds(start, startOfHour(start)); - const offset = Object.entries(delay) - .map(([p, d]) => (position > p ? d : 0)) - .reduce((p, c) => p + c, 0); - const seconds = Math.max(position - offset - 10, 0); + return ( - +
- +
diff --git a/web/src/routes/Recording.jsx b/web/src/routes/Recording.jsx index 5a1083514..e0b704d36 100644 --- a/web/src/routes/Recording.jsx +++ b/web/src/routes/Recording.jsx @@ -1,5 +1,6 @@ import { h } from 'preact'; -import { closestTo, format, parseISO } from 'date-fns'; +import { parseISO, endOfHour, startOfHour, getUnixTime } from 'date-fns'; +import { useEffect, useMemo } from 'preact/hooks'; import ActivityIndicator from '../components/ActivityIndicator'; import Heading from '../components/Heading'; import RecordingPlaylist from '../components/RecordingPlaylist'; @@ -7,15 +8,106 @@ import VideoPlayer from '../components/VideoPlayer'; import { useApiHost } from '../api'; import useSWR from 'swr'; -export default function Recording({ camera, date, hour, seconds }) { - const apiHost = useApiHost(); - const { data } = useSWR(`${camera}/recordings`); +export default function Recording({ camera, date, hour = '00', minute = '00', second = '00' }) { + const currentDate = useMemo( + () => (date ? parseISO(`${date}T${hour || '00'}:${minute || '00'}:${second || '00'}`) : new Date()), + [date, hour, minute, second] + ); - if (!data) { + const apiHost = useApiHost(); + const { data: recordingsSummary } = useSWR(`${camera}/recordings/summary`); + + const recordingParams = { + before: getUnixTime(endOfHour(currentDate)), + after: getUnixTime(startOfHour(currentDate)), + }; + const { data: recordings } = useSWR([`${camera}/recordings`, recordingParams]); + + // calculates the seek seconds by adding up all the seconds in the segments prior to the playback time + const seekSeconds = useMemo(() => { + if (!recordings) { + return 0; + } + const currentUnix = getUnixTime(currentDate); + + const hourStart = getUnixTime(startOfHour(currentDate)); + let seekSeconds = 0; + recordings.every((segment) => { + // if the next segment is past the desired time, stop calculating + if (segment.start_time > currentUnix) { + return false; + } + // if the segment starts before the hour, skip the seconds before the hour + const start = segment.start_time < hourStart ? hourStart : segment.start_time; + // if the segment ends after the selected time, use the selected time for end + const end = segment.end_time > currentUnix ? currentUnix : segment.end_time; + seekSeconds += end - start; + return true; + }); + return seekSeconds; + }, [recordings, currentDate]); + + const playlist = useMemo(() => { + if (!recordingsSummary) { + return []; + } + + const selectedDayRecordingData = recordingsSummary.find((s) => !date || s.day === date); + + const [year, month, day] = selectedDayRecordingData.day.split('-'); + return selectedDayRecordingData.hours + .map((h) => { + return { + name: h.hour, + description: `${camera} recording @ ${h.hour}:00.`, + sources: [ + { + src: `${apiHost}/vod/${year}-${month}/${day}/${h.hour}/${camera}/index.m3u8`, + type: 'application/vnd.apple.mpegurl', + }, + ], + }; + }) + .reverse(); + }, [apiHost, date, recordingsSummary, camera]); + + const playlistIndex = useMemo(() => { + const index = playlist.findIndex((item) => item.name === hour); + if (index === -1) { + return 0; + } + return index; + }, [playlist, hour]); + + useEffect(() => { + if (this.player) { + this.player.playlist(playlist); + } + }, [playlist]); + + useEffect(() => { + if (this.player) { + this.player.playlist.currentItem(playlistIndex); + } + }, [playlistIndex]); + + useEffect(() => { + if (this.player) { + // if the playlist has moved on to the next item, then reset + if (this.player.playlist.currentItem() !== playlistIndex) { + this.player.playlist.currentItem(playlistIndex); + } + this.player.currentTime(seekSeconds); + // try and play since the user is likely to have interacted with the dom + this.player.play(); + } + }, [seekSeconds, playlistIndex]); + + if (!recordingsSummary) { return ; } - if (data.length === 0) { + if (recordingsSummary.length === 0) { return (
{camera} Recordings @@ -27,66 +119,18 @@ export default function Recording({ camera, date, hour, seconds }) { ); } - const recordingDates = data.map((item) => item.date); - const selectedDate = closestTo( - date ? parseISO(date) : new Date(), - recordingDates.map((i) => parseISO(i)) - ); - const selectedKey = format(selectedDate, 'yyyy-MM-dd'); - const [year, month, day] = selectedKey.split('-'); - const playlist = []; - const hours = []; - - for (const item of data) { - if (item.date === selectedKey) { - for (const recording of item.recordings) { - playlist.push({ - name: `${selectedKey} ${recording.hour}:00`, - description: `${camera} recording @ ${recording.hour}:00.`, - sources: [ - { - src: `${apiHost}/vod/${year}-${month}/${day}/${recording.hour}/${camera}/index.m3u8`, - type: 'application/vnd.apple.mpegurl', - }, - ], - }); - hours.push(recording.hour); - } - } - } - - const selectedHour = hours.indexOf(hour); - - if (this.player) { - this.player.playlist([]); - this.player.playlist(playlist); - this.player.playlist.autoadvance(0); - if (selectedHour !== -1) { - this.player.playlist.currentItem(selectedHour); - if (seconds !== undefined) { - this.player.currentTime(seconds); - } - } - // Force playback rate to be correct - const playbackRate = this.player.playbackRate(); - this.player.defaultPlaybackRate(playbackRate); - } - return (
{camera} Recordings { + player.on('ratechange', () => player.defaultPlaybackRate(player.playbackRate())); if (player.playlist) { player.playlist(playlist); player.playlist.autoadvance(0); - if (selectedHour !== -1) { - player.playlist.currentItem(selectedHour); - if (seconds !== undefined) { - player.currentTime(seconds); - } - } + player.playlist.currentItem(playlistIndex); + player.currentTime(seekSeconds); this.player = player; } }} @@ -94,7 +138,7 @@ export default function Recording({ camera, date, hour, seconds }) { this.player = null; }} > - +
);