Fetch timeline events from events endpoint not media browser.

This commit is contained in:
Dermot Duffy
2022-09-24 11:31:03 -07:00
parent 0491018aab
commit a2e80d93a3
13 changed files with 300 additions and 165 deletions
+1
View File
@@ -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",
+1
View File
@@ -216,6 +216,7 @@ export class FrigateCardLive extends LitElement {
html`<frigate-card-surround
.hass=${this.hass}
.view=${this.view}
.fetch=${true}
.thumbnailConfig=${config.controls.thumbnails}
.timelineConfig=${config.controls.timeline}
.browseMediaParams=${browseMediaParams ?? undefined}
+6 -2
View File
@@ -32,7 +32,7 @@ import './surround-basic.js';
import './timeline-core.js';
interface ThumbnailViewContext {
// Whetherr or not to fetch thumbnails.
// Whether or not to fetch thumbnails.
fetch?: boolean;
}
@@ -59,6 +59,9 @@ export class FrigateCardSurround extends LitElement {
@property({ attribute: false })
public inBackground?: boolean;
@property({ attribute: false })
public fetch = false;
@property({ attribute: false, hasChanged: contentsChanged })
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
@@ -76,12 +79,13 @@ export class FrigateCardSurround extends LitElement {
*/
protected async _fetchMedia(): Promise<void> {
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)
) {
+1 -1
View File
@@ -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';
+56 -22
View File
@@ -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 = (<FrigateCardTimelineItem>item).source;
if (!this._isHoverableDevice || !source) {
const event = (<FrigateCardTimelineItem>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 `
<frigate-card-timeline-thumbnail
thumbnail="${source.thumbnail}"
thumbnail="${getEventThumbnailURL(clientId, event)}"
${detailsAttr}
${eventAttr}
label="${source.title}"
label="${getEventTitle(event)}"
>
</frigate-card-timeline-thumbnail>`;
}
@@ -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) {
+1 -1
View File
@@ -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}
>
<frigate-card-timeline-core
.hass=${this.hass}
+1
View File
@@ -137,6 +137,7 @@ export class FrigateCardViewer extends LitElement {
return html` <frigate-card-surround
.hass=${this.hass}
.view=${this.view}
.fetch=${false}
.thumbnailConfig=${this.viewerConfig.controls.thumbnails}
.timelineConfig=${this.viewerConfig.controls.timeline}
.timelineDataManager=${this.timelineDataManager}
+2
View File
@@ -26,6 +26,8 @@ export const THUMBNAIL_WIDTH_MIN = 75;
* Internal types.
*/
export type ClipsOrSnapshots = 'clips' | 'snapshots';
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'live',
'clip',
+10
View File
@@ -1,3 +1,4 @@
import { format } from 'date-fns';
import { isEqual } from 'lodash-es';
import { FrigateCardError } from '../types';
@@ -85,3 +86,12 @@ export function errorToConsole(e: Error, func?: CallableFunction): void {
export const isHoverableDevice = (): boolean => 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');
}
+164 -1
View File
@@ -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<FrigateEvents> => {
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 <T>(
hass: HomeAssistant,
params: Map<T, FrigateGetEventsParameters>,
): Promise<Map<T, FrigateEvents>> => {
const output: Map<T, FrigateEvents> = new Map();
const getEventsAndStore = async (
key: T,
param: FrigateGetEventsParameters,
): Promise<void> => {
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;
}
+7 -58
View File
@@ -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('/');
};
+45 -80
View File
@@ -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<string>,
showRecordings: boolean,
mediaType: TimelineMediaType,
): DataView<FrigateCardTimelineItem> {
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<RecordingSegmentsItem> {
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<void> {
const params: BrowseMediaQueryParameters[] = [];
const params: Map<string, FrigateGetEventsParameters> = 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<BrowseMediaQueryParameters, FrigateBrowseMediaSource>;
let results: Map<string, FrigateEvents>;
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));
}
}
+5
View File
@@ -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"