From a2e80d93a39fac33efd42044bb52ad9bd07860bd Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 24 Sep 2022 11:31:03 -0700 Subject: [PATCH] Fetch timeline events from events endpoint not media browser. --- package.json | 1 + src/components/live.ts | 1 + src/components/surround.ts | 8 +- src/components/thumbnail.ts | 2 +- src/components/timeline-core.ts | 78 ++++++++++---- src/components/timeline.ts | 2 +- src/components/viewer.ts | 1 + src/types.ts | 2 + src/utils/basic.ts | 10 ++ src/utils/frigate.ts | 165 ++++++++++++++++++++++++++++- src/utils/ha/browse-media.ts | 65 ++---------- src/utils/timeline-data-manager.ts | 125 ++++++++-------------- yarn.lock | 5 + 13 files changed, 300 insertions(+), 165 deletions(-) diff --git a/package.json b/package.json index 0cbdd131..535bfa79 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", "date-fns": "^2.29.2", + "date-fns-tz": "^1.3.7", "embla-carousel": "^7.0.2", "embla-carousel-wheel-gestures": "^3.0.0", "home-assistant-js-websocket": "^8.0.0", diff --git a/src/components/live.ts b/src/components/live.ts index 21e22273..5efec367 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -216,6 +216,7 @@ export class FrigateCardLive extends LitElement { html` { if ( + !this.fetch || this.inBackground || !this.hass || !this.view || + this.view.target || !this.thumbnailConfig || this.thumbnailConfig.mode === 'none' || - this.view.target || !this.browseMediaParams || !(this.view.context?.thumbnails?.fetch ?? true) ) { diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index af124630..8468515d 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -11,7 +11,7 @@ import thumbnailStyle from '../scss/thumbnail.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import { retainEvent } from '../utils/frigate.js'; -import { getEventDurationString } from '../utils/ha/browse-media.js'; +import { getEventDurationString } from '../utils/frigate.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { View } from '../view.js'; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index ac18e7e3..a02a1771 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -47,16 +47,19 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { contentsChanged, dispatchFrigateCardEvent, + formatDateAndTime, isHoverableDevice, prettifyTitle, } from '../utils/basic'; import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; - import { - createEventParentForChildren, - createVideoChild, - generateRecordingIdentifier, -} from '../utils/ha/browse-media'; + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingMediaContentID, +} from '../utils/frigate'; + +import { createEventParentForChildren, createChild } from '../utils/ha/browse-media'; import { FrigateCardTimelineItem, RecordingSegmentsItem, @@ -216,16 +219,17 @@ export class FrigateCardTimelineCore extends LitElement { * @returns The tooltip as a string to render. */ protected _getTooltip(item: TimelineItem): string { - const source = (item).source; - if (!this._isHoverableDevice || !source) { + const event = (item).event; + const clientId = item.group + ? this.cameras?.get(String(item.group))?.frigate.client_id + : null; + if (!this._isHoverableDevice || !event || !clientId) { // Don't display tooltips on touch devices, they just get in the way of // the drawer. return ''; } - const eventAttr = source.frigate?.event - ? `event='${JSON.stringify(source.frigate.event)}'` - : ''; + const eventAttr = `event='${JSON.stringify(event)}'`; const detailsAttr = this.thumbnailDetails ? 'details' : ''; // Cannot use Lit data-bindings as visjs requires a string for tooltips. @@ -233,10 +237,10 @@ export class FrigateCardTimelineCore extends LitElement { // whitelist in `_getOptions()` . return ` `; } @@ -324,12 +328,9 @@ export class FrigateCardTimelineCore extends LitElement { // hours, otherwise only show the matching hour from all cameras. if (!onlyShowMatchingHour || isMatchingHour) { children.push( - createVideoChild( - `${prettifyTitle(config.frigate.camera_name)} ${format( - hour, - 'yyyy-MM-dd HH:mm', - )}`, - generateRecordingIdentifier({ + createChild( + `${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`, + getRecordingMediaContentID({ clientId: config.frigate.client_id, year: dayData.day.getFullYear(), month: dayData.day.getMonth() + 1, @@ -812,9 +813,40 @@ export class FrigateCardTimelineCore extends LitElement { let childIndex = -1; const children: FrigateBrowseMediaSource[] = []; this._dataview?.get({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { - if (item.event && item.source) { - children.push(item.source); - if (selected.includes(item.event.id)) { + const cameraID = item.group ? String(item.group) : null; + const cameraConfig = cameraID ? this.cameras?.get(cameraID) : null; + const event = item.event; + const media = + event?.has_clip && this.timelineConfig?.media !== 'snapshots' + ? 'clips' + : event?.has_snapshot + ? 'snapshots' + : null; + + if ( + cameraID && + cameraConfig && + event && + media && + cameraConfig.frigate.camera_name + ) { + children.push( + createChild( + getEventTitle(event), + getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + event.id, + media, + ), + { + thumbnail: getEventThumbnailURL(cameraConfig.frigate.client_id, event), + event: event, + cameraID: cameraID, + }, + ), + ); + if (selected.includes(event.id)) { childIndex = children.length - 1; } } @@ -1190,6 +1222,7 @@ export class FrigateCardTimelineCore extends LitElement { this.timelineDataManager && this._refTimeline.value && options && + this.timelineConfig && (changedProperties.has('timelineConfig') || (this.mini && changedProperties.has('view') && @@ -1212,7 +1245,8 @@ export class FrigateCardTimelineCore extends LitElement { this._dataview = this.timelineDataManager.createDataView( this._getTimelineCameraIDs(), - !!this.timelineConfig?.show_recordings, + !!this.timelineConfig.show_recordings, + this.timelineConfig.media, ); if (this.mini && groups.length === 1) { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 55281053..756ad4d5 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,7 +1,6 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) // TODO: delete segments if not in summary? is this actually necessary? could it create gaps in data? better off stopping access via summary? -// TODO: support filtering created dataviews by recordings or mediatype (so storage ) // TODO: Make minitimeline configurable in the editor import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; @@ -49,6 +48,7 @@ export class FrigateCardTimeline extends LitElement { .view=${this.view} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameras=${this.cameras} + .fetch=${false} > window.matchMedia( '(hover: hover) and (pointer: fine)', ).matches; + +/** + * Format a date object to RFC3339. + * @param date A Date object. + * @returns A date and time. + */ +export const formatDateAndTime = (date: Date): string => { + return format(date, 'yyyy-MM-dd HH:mm'); +} \ No newline at end of file diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index 6c61da10..c5ef776a 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -1,7 +1,21 @@ import { HomeAssistant } from 'custom-card-helpers'; +import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; +import differenceInHours from 'date-fns/differenceInHours'; +import differenceInMinutes from 'date-fns/differenceInMinutes'; +import differenceInSeconds from 'date-fns/differenceInSeconds'; +import fromUnixTime from 'date-fns/fromUnixTime'; import { z } from 'zod'; import { localize } from '../localize/localize'; -import { ExtendedHomeAssistant, FrigateCardError } from '../types'; +import { + BrowseRecordingQueryParameters, + ClipsOrSnapshots, + ExtendedHomeAssistant, + FrigateCardError, + FrigateEvent, + FrigateEvents, + frigateEventsSchema, +} from '../types'; +import { formatDateAndTime, prettifyTitle } from './basic'; import { homeAssistantWSRequest } from './ha'; export const FRIGATE_ICON_SVG_PATH = @@ -144,3 +158,152 @@ export async function retainEvent( }); } } + +export interface FrigateGetEventsParameters { + instance_id?: string; + camera?: string; + label?: string; + zone?: string; + after?: number; + before?: number; + limit?: number; + has_clip?: boolean; + has_snapshot?: boolean; +} + +/** + * Get events over websocket. May throw. + * @param hass The Home Assistant object. + * @param params The events search parameters. + * @returns An array of 'FrigateEvent's. + */ +export const getEvents = async ( + hass: HomeAssistant, + params?: FrigateGetEventsParameters, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateEventsSchema, + { + type: 'frigate/events/get', + ...params, + }, + true, + ); +}; + +/** + * Get multiple sets of events. + * @param hass The Home Assistant object. + * @param params A Map of parameters keyed on any key. + * @returns A Map of key -> events. + */ +export const getEventsMultiple = async ( + hass: HomeAssistant, + params: Map, +): Promise> => { + const output: Map = new Map(); + const getEventsAndStore = async ( + key: T, + param: FrigateGetEventsParameters, + ): Promise => { + output.set(key, await getEvents(hass, param)); + }; + await Promise.all( + Array.from(params).map(([key, param]) => getEventsAndStore(key, param)), + ); + return output; +}; + +/** + * Given an event generate a title. + * @param event + */ +export const getEventTitle = (event: FrigateEvent): string => { + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const durationSeconds = Math.round( + event.end_time + ? event.end_time - event.start_time + : Date.now() / 1000 - event.start_time, + ); + return `${formatDateAndTime( + utcToZonedTime(event.start_time * 1000, localTimezone), + )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( + event.top_score * 100, + )}%]`; +}; + +/** + * Get a thumbnail URL for an event. + * @param clientId The Frigate client id. + * @param event The event. + * @returns A string URL. + */ +export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { + return `/api/frigate/${clientId}/thumbnail/${event.id}`; +}; + +/** + * Get a media content ID for an event. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param id The event id. + * @param mediaType The media type required. + * @returns A string media content id. + */ +export const getEventMediaContentID = ( + clientId: string, + cameraName: string, + id: string, + mediaType: ClipsOrSnapshots, +): string => { + return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${id}`; +}; + +/** + * Generate a recording identifier. + * @param hass The HomeAssistant object. + * @param params The recording parameters to use in the identifer. + * @returns A recording identifier. + */ +export const getRecordingMediaContentID = ( + params: BrowseRecordingQueryParameters, +): string => { + return [ + 'media-source://frigate', + params.clientId, + 'recordings', + `${params.year}-${String(params.month).padStart(2, '0')}`, + String(params.day).padStart(2, '0'), + String(params.hour).padStart(2, '0'), + params.cameraName, + ].join('/'); +}; + +/** + * Convenience function to convert a timestamp to hours, minutes and seconds + * string. Heavily inspired by, and returning the same format as, the Frigate + * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 + * @param event The Frigate event. + * @returns A duration string. + */ +export function getEventDurationString(event: FrigateEvent): string { + if (!event.end_time) { + return localize('event.in_progress'); + } + const start = fromUnixTime(event.start_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; + let duration = ''; + + if (hours) { + duration += `${hours}h `; + } + if (minutes) { + duration += `${minutes}m `; + } + duration += `${seconds}s`; + return duration; +} diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index e1ab9ef7..46086c2c 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -1,10 +1,4 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { - differenceInHours, - differenceInMinutes, - differenceInSeconds, - fromUnixTime, -} from 'date-fns'; import { homeAssistantWSRequest } from '.'; import { dispatchErrorMessageEvent, @@ -14,7 +8,6 @@ import { import { localize } from '../../localize/localize.js'; import { BrowseMediaQueryParameters, - BrowseRecordingQueryParameters, CameraConfig, FrigateBrowseMediaSource, frigateBrowseMediaSourceSchema, @@ -413,16 +406,17 @@ export const createEventParentForChildren = ( /** * Given a media video child with a given media_content_id. * @param title The title to use for the child. - * @param media_con + * @param mediaContentID The media content id to use for the child. * @param children The children media items. * @returns A single parent containing the children. */ -export const createVideoChild = ( +export const createChild = ( title: string, mediaContentID: string, options?: { thumbnail?: string; recording?: FrigateRecording; + event?: FrigateEvent; cameraID?: string, }, ): FrigateBrowseMediaSource => { @@ -436,8 +430,11 @@ export const createVideoChild = ( thumbnail: options?.thumbnail ?? null, children: null } - if (options?.recording || options?.cameraID) { + if (options?.recording || options?.cameraID || options?.event) { result.frigate = {} + if (options?.event) { + result.frigate.event = options.event; + } if (options?.recording) { result.frigate.recording = options.recording; } @@ -447,51 +444,3 @@ export const createVideoChild = ( } return result; }; - -/** - * Convenience function to convert a timestamp to hours, minutes and seconds - * string. Heavily inspired by, and returning the same format as, the Frigate - * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 - * @param event The Frigate event. - * @returns A duration string. - */ -export function getEventDurationString(event: FrigateEvent): string { - if (!event.end_time) { - return localize('event.in_progress'); - } - const start = fromUnixTime(event.start_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; - let duration = ''; - - if (hours) { - duration += `${hours}h `; - } - if (minutes) { - duration += `${minutes}m `; - } - duration += `${seconds}s`; - return duration; -} - -/** - * Generate a recording identifier. - * @param hass The HomeAssistant object. - * @param params The recording parameters to use in the identifer. - * @returns A recording identifier. - */ -export const generateRecordingIdentifier = ( - params: BrowseRecordingQueryParameters, -): string => { - return [ - 'media-source://frigate', - params.clientId, - 'recordings', - `${params.year}-${String(params.month).padStart(2, '0')}`, - String(params.day).padStart(2, '0'), - String(params.hour).padStart(2, '0'), - params.cameraName, - ].join('/'); -}; diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts index af6deb44..489e490d 100644 --- a/src/utils/timeline-data-manager.ts +++ b/src/utils/timeline-data-manager.ts @@ -3,29 +3,26 @@ import { DataSet, DataView } from 'vis-data/esnext'; import { IdType, TimelineItem } from 'vis-timeline/esnext'; import { CAMERA_BIRDSEYE } from '../const.js'; import { - BrowseMediaQueryParameters, CameraConfig, ExtendedHomeAssistant, - FrigateBrowseMediaSource, FrigateCardError, FrigateEvent, + FrigateEvents, } from '../types.js'; import { errorToConsole } from '../utils/basic.js'; import { + FrigateGetEventsParameters, + getEventsMultiple, getRecordingSegments, getRecordingsSummary, RecordingSegments, RecordingSummary, } from './frigate.js'; -import { - getBrowseMediaQueryParameters, - isTrueMedia, - multipleBrowseMediaQuery, -} from './ha/browse-media.js'; import { dispatchFrigateCardErrorEvent } from '../components/message.js'; const RECORDING_SEGMENT_TOLERANCE = 60; const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10; +const TIMELINE_DATA_MANAGER_MAX_FETCH_COUNT = 10000; export interface FrigateCardTimelineItem extends TimelineItem { // DataView has issues using datasets with Date objects, so avoid them and use @@ -33,7 +30,6 @@ export interface FrigateCardTimelineItem extends TimelineItem { start: number; end?: number; event?: FrigateEvent; - source?: FrigateBrowseMediaSource; } type TimelineMediaType = 'all' | 'clips' | 'snapshots'; @@ -126,23 +122,27 @@ export class TimelineDataManager { * Create a dataview for a given set of camera. * @param cameraIDs The cameraIDs to include. * @param showRecordings Whether or not to show recordings. - * @returns + * @returns A dataview. */ public createDataView( cameraIDs: Set, showRecordings: boolean, + mediaType: TimelineMediaType, ): DataView { return new DataView(this._dataset, { filter: (item: FrigateCardTimelineItem) => !!item.group && cameraIDs.has(String(item.group)) && - (showRecordings || item.type !== 'background'), + (showRecordings || item.type !== 'background') && + (mediaType === 'all' || + (mediaType === 'clips' && !!item.event?.has_clip) || + (mediaType === 'snapshots' && !!item.event?.has_snapshot)), }); } /** * Create a dataview for segments. - * @returns + * @returns A dataview. */ public createSegmentDataView(): DataView { return new DataView(this._recordingSegments); @@ -171,51 +171,22 @@ export class TimelineDataManager { } /** - * Add a FrigateBrowseMediaSource object to the managed timeline. - * @param cameraID The id the camera this object is from. - * @param target The FrigateBrowseMediaSource to add. + * Add events for the given camera. + * @param cameraID The camera ID. + * @param events The array of events. */ - protected _addMediaSource(target: FrigateBrowseMediaSource): void { - const items: FrigateCardTimelineItem[] = []; - target.children?.forEach((child) => { - const event = child.frigate?.event; - const cameraID = child.frigate?.cameraID; - if ( - cameraID && - event && - isTrueMedia(child) && - ['video', 'image'].includes(child.media_content_type) - ) { - let item = this._dataset.get(event.id); - if (!item) { - item = { - id: event.id, - group: cameraID, - content: '', - start: event.start_time * 1000, - event: event, - }; - } - if ( - (child.media_content_type === 'video' && - ['all', 'clips'].includes(this._mediaType)) || - (!item.source && - child.media_content_type === 'image' && - ['all', 'snapshots'].includes(this._mediaType)) - ) { - item.source = child; - } - if (event.end_time) { - item['end'] = event.end_time * 1000; - item['type'] = 'range'; - } else { - item['type'] = 'point'; - } - items.push(item); - } - }); - - this._dataset.update(items); + protected _addEvents(cameraID: string, events: FrigateEvents): void { + this._dataset.update( + events.map((event) => ({ + id: event.id, + group: cameraID, + content: '', + event: event, + start: event.start_time * 1000, + type: event.end_time ? 'range' : 'point', + ...(event.end_time && { end: event.end_time * 1000 }), + })), + ); } /** @@ -485,37 +456,31 @@ export class TimelineDataManager { start: Date, end: Date, ): Promise { - const params: BrowseMediaQueryParameters[] = []; + const params: Map = new Map(); + this._cameras.forEach((cameraConfig, cameraID) => { - (this._mediaType === 'all' ? ['clips', 'snapshots'] : [this._mediaType]).forEach( - (mediaType) => { - if (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE) { - const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, { - before: end.getTime() / 1000, - after: start.getTime() / 1000, - unlimited: true, - mediaType: mediaType as 'clips' | 'snapshots', - }); - if (param) { - params.push(param); - } - } - }, - ); + if ( + cameraConfig.frigate.camera_name && + cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE + ) { + params.set(cameraID, { + instance_id: cameraConfig.frigate.client_id, + camera: cameraConfig.frigate.camera_name, + ...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }), + ...(cameraConfig.frigate.zone && { label: cameraConfig.frigate.zone }), + before: Math.floor(end.getTime() / 1000), + after: Math.floor(start.getTime() / 1000), + limit: TIMELINE_DATA_MANAGER_MAX_FETCH_COUNT, + }); + } }); - if (!params.length) { - return; - } - - let results: Map; + let results: Map; try { - results = await multipleBrowseMediaQuery(hass, params); + results = await getEventsMultiple(hass, params); } catch (e) { return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); } - for (const result of results.values()) { - this._addMediaSource(result); - } + results.forEach((params, cameraID) => this._addEvents(cameraID, params)); } } diff --git a/yarn.lock b/yarn.lock index 3816386d..7f92bffb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1060,6 +1060,11 @@ custom-card-helpers@^1.9.0: superstruct "^0.15.3" typescript "^4.5.4" +date-fns-tz@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-1.3.7.tgz#e8e9d2aaceba5f1cc0e677631563081fdcb0e69a" + integrity sha512-1t1b8zyJo+UI8aR+g3iqr5fkUHWpd58VBx8J/ZSQ+w7YrGlw80Ag4sA86qkfCXRBLmMc4I2US+aPMd4uKvwj5g== + date-fns@^2.29.2: version "2.29.2" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931"