Initial code for major engine refactor.
This commit is contained in:
+54
-10
@@ -1,7 +1,12 @@
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||
import format from 'date-fns/format';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { FrigateCardError } from '../types';
|
||||
|
||||
export type ModifyInterface<T, R> = Omit<T, keyof R> & R;
|
||||
|
||||
/**
|
||||
* Dispatch a Frigate Card event.
|
||||
* @param element The element to send the event.
|
||||
@@ -51,6 +56,24 @@ export function arrayMove(target: unknown[], from: number, to: number): void {
|
||||
target.splice(to, 0, element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to an array if it is not already one.
|
||||
* @param value: A value (which may be an array).
|
||||
* @returns An array.
|
||||
*/
|
||||
export const arrayify = <T>(value: T | T[]): T[] => {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a value to an set if it is not already one.
|
||||
* @param value: A value (which may be a set, an array or a T)
|
||||
* @returns A set of T.
|
||||
*/
|
||||
export const setify = <T>(value: T | T[] | Set<T>): Set<T> => {
|
||||
return value instanceof Set ? value : new Set(arrayify(value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if the contents of the n(ew) and o(ld) values have changed. For use
|
||||
* in lit web components that may have a value that changes address but not
|
||||
@@ -68,10 +91,7 @@ export function contentsChanged(n: unknown, o: unknown): boolean {
|
||||
* @param e The Error object.
|
||||
* @param func The Console func to call.
|
||||
*/
|
||||
export function errorToConsole(e: Error, func?: CallableFunction): void {
|
||||
if (!func) {
|
||||
func = console.warn;
|
||||
}
|
||||
export function errorToConsole(e: Error, func: CallableFunction = console.warn): void {
|
||||
if (e instanceof FrigateCardError && e.context) {
|
||||
func(e, e.context);
|
||||
} else {
|
||||
@@ -83,9 +103,8 @@ export function errorToConsole(e: Error, func?: CallableFunction): void {
|
||||
* Determine if the device supports hovering.
|
||||
* @returns `true` if the device supports hovering, `false` otherwise.
|
||||
*/
|
||||
export const isHoverableDevice = (): boolean => window.matchMedia(
|
||||
'(hover: hover) and (pointer: fine)',
|
||||
).matches;
|
||||
export const isHoverableDevice = (): boolean =>
|
||||
window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
|
||||
/**
|
||||
* Format a date object to RFC3339.
|
||||
@@ -94,7 +113,7 @@ export const isHoverableDevice = (): boolean => window.matchMedia(
|
||||
*/
|
||||
export const formatDateAndTime = (date: Date): string => {
|
||||
return format(date, 'yyyy-MM-dd HH:mm');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a function in idle periods. If idle callbacks are not supported (e.g.
|
||||
@@ -105,9 +124,34 @@ export const formatDateAndTime = (date: Date): string => {
|
||||
export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => {
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(func, {
|
||||
...(timeout && { timeout: timeout})
|
||||
...(timeout && { timeout: timeout }),
|
||||
});
|
||||
} else {
|
||||
func();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience function to return a string representing the difference in hours,
|
||||
* minutes and seconds between two dates. 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 start The start date.
|
||||
* @param end The end date.
|
||||
* @returns A duration string.
|
||||
*/
|
||||
export function getDurationString(start: Date, end: Date): string {
|
||||
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;
|
||||
}
|
||||
|
||||
+6
-37
@@ -81,13 +81,13 @@ export function getCameraIcon(
|
||||
*/
|
||||
export const getAllDependentCameras = (
|
||||
cameras: Map<string, CameraConfig>,
|
||||
camera?: string,
|
||||
cameraID?: string,
|
||||
): Set<string> => {
|
||||
const cameraIDs: Set<string> = new Set();
|
||||
const getDependentCameras = (camera: string): void => {
|
||||
const cameraConfig = cameras.get(camera);
|
||||
const getDependentCameras = (cameraID: string): void => {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (cameraConfig) {
|
||||
cameraIDs.add(camera);
|
||||
cameraIDs.add(cameraID);
|
||||
const dependentCameras: Set<string> = new Set();
|
||||
(cameraConfig.dependencies.cameras || []).forEach((item) =>
|
||||
dependentCameras.add(item),
|
||||
@@ -102,39 +102,8 @@ export const getAllDependentCameras = (
|
||||
}
|
||||
}
|
||||
};
|
||||
if (camera) {
|
||||
getDependentCameras(camera);
|
||||
if (cameraID) {
|
||||
getDependentCameras(cameraID);
|
||||
}
|
||||
return cameraIDs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the cameraIDs of truly unique cameras (some configured cameras may be
|
||||
* the same Frigate came but with different zone/labels).
|
||||
* @param cameras The full set of cameras.
|
||||
* @param cameraIDs The specific IDs to dedup.
|
||||
*/
|
||||
export const getTrueCameras = (
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): Set<string> => {
|
||||
const getTrueCameraID = (cameraConfig: CameraConfig): string => {
|
||||
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
||||
cameraConfig.frigate.camera_name ?? ''
|
||||
}`;
|
||||
};
|
||||
|
||||
const output = new Set<string>();
|
||||
const visitedTrueCameras = new Set<string>();
|
||||
cameraIDs.forEach((cameraID: string) => {
|
||||
const cameraConfig = cameras.get(cameraID) ?? null;
|
||||
if (cameraConfig && cameraConfig.frigate.camera_name) {
|
||||
const trueCameraID = getTrueCameraID(cameraConfig);
|
||||
if (!visitedTrueCameras.has(trueCameraID)) {
|
||||
output.add(cameraID);
|
||||
visitedTrueCameras.add(trueCameraID);
|
||||
}
|
||||
}
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { DataSet, DataView } from 'vis-data/esnext';
|
||||
import type { IdType, TimelineItem } from 'vis-timeline/esnext';
|
||||
import { CAMERA_BIRDSEYE } from '../const.js';
|
||||
import {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
FrigateEvents,
|
||||
} from '../types.js';
|
||||
import { errorToConsole, runWhenIdleIfSupported } from './basic.js';
|
||||
import {
|
||||
FrigateGetEventsParameters,
|
||||
getEventsMultiple,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
RecordingSegments,
|
||||
RecordingSummary,
|
||||
} from './frigate.js';
|
||||
import { dispatchFrigateCardErrorEvent } from '../components/message.js';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
|
||||
const RECORDING_SEGMENT_TOLERANCE = 60;
|
||||
const DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
||||
const DATA_MANAGER_MAX_FETCH_COUNT = 10000;
|
||||
|
||||
export interface FrigateCardTimelineItem extends TimelineItem {
|
||||
// DataView has issues using datasets with Date objects, so avoid them and use
|
||||
// numbers instead.
|
||||
start: number;
|
||||
end?: number;
|
||||
event?: FrigateEvent;
|
||||
}
|
||||
|
||||
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
|
||||
|
||||
export interface RecordingSegmentsItem {
|
||||
id: string;
|
||||
cameraID: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the timeline items most recent to least recent.
|
||||
* @param a The first item.
|
||||
* @param b The second item.
|
||||
* @returns -1, 0, 1 (standard array sort function configuration).
|
||||
*/
|
||||
export const sortYoungestToOldest = (
|
||||
a: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
b: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
): number => {
|
||||
if (a.start < b.start) {
|
||||
return 1;
|
||||
}
|
||||
if (a.start > b.start) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort the segments least recent to most recent.
|
||||
* @param a The first item.
|
||||
* @param b The second item.
|
||||
* @returns -1, 0, 1 (standard array sort function configuration).
|
||||
*/
|
||||
export const sortOldestToYoungest = (
|
||||
a: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
b: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
): number => {
|
||||
if (a.start < b.start) {
|
||||
return -1;
|
||||
}
|
||||
if (a.start > b.start) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A manager to maintain/fetch timeline events.
|
||||
*/
|
||||
export class DataManager {
|
||||
protected _recordingSummary: Map<string, RecordingSummary | null> = new Map();
|
||||
protected _recordingSegments = new DataSet<RecordingSegmentsItem>();
|
||||
|
||||
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
||||
|
||||
// The earliest date managed.
|
||||
protected _dateStart: Date | null = null;
|
||||
|
||||
// The latest date managed.
|
||||
protected _dateEnd: Date | null = null;
|
||||
|
||||
// The last fetch date.
|
||||
protected _dateFetch: Date | null = null;
|
||||
|
||||
// The maximum allowable age of fetch data (will not fetch more frequently
|
||||
// than this).
|
||||
protected _maxAgeSeconds: number = DATA_MANAGER_MAX_AGE_SECONDS;
|
||||
|
||||
protected _cameras: Map<string, CameraConfig>;
|
||||
|
||||
// Garbage collect segments at most once an hour.
|
||||
protected _throttledSegmentGarbageCollector = throttle(
|
||||
() => {
|
||||
runWhenIdleIfSupported(this._garbageCollectSegments.bind(this));
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
constructor(cameras: Map<string, CameraConfig>) {
|
||||
this._cameras = cameras;
|
||||
}
|
||||
|
||||
// Get the last event fetch date.
|
||||
get lastFetchDate(): Date | null {
|
||||
return this._dateFetch ?? null;
|
||||
}
|
||||
|
||||
public getRecordingSummaryForCamera(cameraID: string): RecordingSummary | null {
|
||||
return this._recordingSummary.get(cameraID) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dataview for a given set of camera.
|
||||
* @param cameraIDs The cameraIDs to include.
|
||||
* @param showRecordings Whether or not to show recordings.
|
||||
* @returns A dataview.
|
||||
*/
|
||||
public createDataView(
|
||||
cameraIDs: Set<string>,
|
||||
showRecordings: boolean,
|
||||
mediaType: TimelineMediaType,
|
||||
): DataView<FrigateCardTimelineItem> {
|
||||
return new DataView(this._dataset, {
|
||||
filter: (item: FrigateCardTimelineItem) =>
|
||||
// Only return items for the given cameras.
|
||||
!!item.group &&
|
||||
cameraIDs.has(String(item.group)) &&
|
||||
// Don't return recordings if the user does not want them.
|
||||
(showRecordings || item.type !== 'background') &&
|
||||
// Don't return events that are the wrong media type.
|
||||
(item.type === 'background' ||
|
||||
mediaType === 'all' ||
|
||||
(mediaType === 'clips' && !!item.event?.has_clip) ||
|
||||
(mediaType === 'snapshots' && !!item.event?.has_snapshot)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dataview for segments.
|
||||
* @returns A dataview.
|
||||
*/
|
||||
public createSegmentDataView(): DataView<RecordingSegmentsItem> {
|
||||
return new DataView(this._recordingSegments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying recording segments dataset.
|
||||
*/
|
||||
get recordingSegments(): DataSet<RecordingSegmentsItem> {
|
||||
return this._recordingSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an item as-is. May be useful in cases where clustering may need to
|
||||
* be recalculated.
|
||||
* @param id The id to rewrite.
|
||||
*/
|
||||
public rewriteItem(id: IdType): void {
|
||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||
// update the dataset to ensure the newly selected item cannot be included
|
||||
// in a cluster.
|
||||
const item = this._dataset.get(id);
|
||||
if (item) {
|
||||
this._dataset.updateOnly(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add events for the given camera.
|
||||
* @param cameraID The camera ID.
|
||||
* @param events The array of events.
|
||||
*/
|
||||
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 }),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the timeline has coverage for a given range of dates.
|
||||
* @param start The start of the date range.
|
||||
* @param end An optional end of the date range.
|
||||
* @returns
|
||||
*/
|
||||
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
|
||||
// Never fetched: no coverage.
|
||||
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the most recent fetch is older than maxAgeSeconds: no coverage.
|
||||
if (
|
||||
this._maxAgeSeconds &&
|
||||
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the most requested data is earlier than the earliest stored: no
|
||||
// coverage.
|
||||
if (start < this._dateStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there's no end time specified: there IS coverage.
|
||||
if (!end) {
|
||||
return true;
|
||||
}
|
||||
// If the requested end time is older than the oldest requested: there IS
|
||||
// coverage.
|
||||
if (end.getTime() < this._dateEnd.getTime()) {
|
||||
return true;
|
||||
}
|
||||
// If there's no maxAgeSeconds specified: no coverage.
|
||||
if (!this._maxAgeSeconds) {
|
||||
return false;
|
||||
}
|
||||
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
|
||||
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// End time is within `_maxAgeSeconds` of the latest data: there IS
|
||||
// coverage.
|
||||
return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events if no coverage in given range.
|
||||
* @param element The element to send error events from.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param start Fetch events that start later than this date.
|
||||
* @param end Fetch events that start earlier than this date.
|
||||
* @returns `true` if events were fetched, `false` otherwise.
|
||||
*/
|
||||
public async fetchIfNecessary(
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<boolean> {
|
||||
// Cannot fetch the future, always clip the end date to now so as to avoid
|
||||
// checking for coverage that could not possibly exist yet.
|
||||
const now = new Date();
|
||||
end = end > now ? now : end;
|
||||
|
||||
if (this.hasCoverage(now, start, end)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldStart = this._dateStart;
|
||||
const oldEnd = this._dateEnd;
|
||||
let segmentStart: Date | null = null;
|
||||
let segmentEnd: Date | null = null;
|
||||
if (!this._dateStart || start < this._dateStart) {
|
||||
this._dateStart = start;
|
||||
segmentStart = start;
|
||||
} else {
|
||||
segmentStart = oldEnd ?? end;
|
||||
}
|
||||
if (!this._dateEnd || end > this._dateEnd) {
|
||||
this._dateEnd = end;
|
||||
segmentEnd = end;
|
||||
} else {
|
||||
segmentEnd = oldStart ?? start;
|
||||
}
|
||||
|
||||
this._dateFetch = new Date();
|
||||
|
||||
await Promise.all([
|
||||
// Events are always fetched for the maximum extent of the managed
|
||||
// range. This is because events may change at any point in time
|
||||
// (e.g. a long-running event that ends).
|
||||
this._fetchEvents(element, hass, this._dateStart, this._dateEnd),
|
||||
this._fetchRecordingSummary(hass),
|
||||
...(segmentEnd > segmentStart
|
||||
? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)]
|
||||
: []),
|
||||
]);
|
||||
|
||||
this._throttledSegmentGarbageCollector();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect recording segments that no longer feature in the summary.
|
||||
*/
|
||||
protected _garbageCollectSegments(): void {
|
||||
if (!this._recordingSegments || !this._recordingSummary) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
|
||||
// items) and each item must be examined, so care required here to stick to
|
||||
// nothing worse than O(n) performance.
|
||||
const getHourID = (cameraID: string, day: number, hour: number): string => {
|
||||
return `${cameraID}/${day}/${hour}`;
|
||||
};
|
||||
|
||||
const goodHours: Set<string> = new Set();
|
||||
for (const cameraID of this._recordingSummary.keys()) {
|
||||
for (const summaryDay of this._recordingSummary?.get(cameraID) ?? []) {
|
||||
for (const summaryHour of summaryDay.hours) {
|
||||
goodHours.add(getHourID(cameraID, summaryDay.day.getDate(), summaryHour.hour));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteIDs: string[] = [];
|
||||
this._recordingSegments.forEach((item, id) => {
|
||||
const startDate = fromUnixTime(item.start / 1000);
|
||||
const hourID = getHourID(item.cameraID, startDate.getDate(), startDate.getHours());
|
||||
|
||||
// ~O(1) lookup time for a JS set.
|
||||
if (!goodHours.has(hourID)) {
|
||||
deleteIDs.push(String(id));
|
||||
}
|
||||
});
|
||||
|
||||
this._recordingSegments.remove(deleteIDs);
|
||||
this._compressRecordingSegmentsOntoTimeline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recording segments for cameras.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param start Fetch segments that start later than this date.
|
||||
* @param end Fetch segments that start earlier than this date.
|
||||
*/
|
||||
protected async _fetchRecordingSegments(
|
||||
hass: ExtendedHomeAssistant,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<void> {
|
||||
const results: Map<string, RecordingSegments> = new Map();
|
||||
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
|
||||
if (!config || !config.frigate.camera_name || !hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cameraResults = await getRecordingSegments(
|
||||
hass,
|
||||
config.frigate.client_id,
|
||||
config.frigate.camera_name,
|
||||
end,
|
||||
start,
|
||||
);
|
||||
results.set(camera, cameraResults);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this._cameras.keys()).map((camera) =>
|
||||
fetch(camera, this._cameras.get(camera)),
|
||||
),
|
||||
);
|
||||
|
||||
const items: RecordingSegmentsItem[] = [];
|
||||
results.forEach((segments, cameraID) => {
|
||||
segments.forEach((segment) => {
|
||||
items.push({
|
||||
id: `${cameraID}/${segment.id}`,
|
||||
cameraID: cameraID,
|
||||
start: segment.start_time * 1000,
|
||||
end: segment.end_time * 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
this._recordingSegments.update(items);
|
||||
this._compressRecordingSegmentsOntoTimeline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress recording segments into recordings shown on the timeline
|
||||
* background.
|
||||
*/
|
||||
protected _compressRecordingSegmentsOntoTimeline(): void {
|
||||
if (!this._recordingSegments.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all the existing background.
|
||||
this._dataset.remove(
|
||||
this._dataset.get({
|
||||
filter: (item) => item.type === 'background',
|
||||
}),
|
||||
);
|
||||
|
||||
const convertToRecording = (
|
||||
segment: RecordingSegmentsItem,
|
||||
): FrigateCardTimelineItem => {
|
||||
return {
|
||||
id: `recording-${segment.cameraID}-${segment.id}`,
|
||||
group: segment.cameraID,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
content: ' ',
|
||||
type: 'background',
|
||||
};
|
||||
};
|
||||
|
||||
// Iterate through the segments least to most recent, effectively joining
|
||||
// segments together that are within a certain tolerance to create large
|
||||
// blocks that are visualized on the timeline as recordings.
|
||||
const recordings: FrigateCardTimelineItem[] = [];
|
||||
|
||||
this._cameras.forEach((_, cameraID) => {
|
||||
const segments = this._recordingSegments.get({
|
||||
filter: (item) => item.cameraID === cameraID,
|
||||
order: sortOldestToYoungest,
|
||||
});
|
||||
let current: RecordingSegmentsItem | null = null;
|
||||
for (let i = 0; i < segments.length; ++i) {
|
||||
const item = segments[i];
|
||||
|
||||
if (!current) {
|
||||
current = { ...item };
|
||||
} else if (current.end + RECORDING_SEGMENT_TOLERANCE * 1000 >= item.start) {
|
||||
current.end = item.end;
|
||||
} else {
|
||||
recordings.push(convertToRecording(current));
|
||||
current = null;
|
||||
}
|
||||
if (i === segments.length - 1 && current) {
|
||||
recordings.push(convertToRecording(current));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._dataset.update(recordings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recording summary.
|
||||
* @param hass The HomeAssistant object.
|
||||
*/
|
||||
protected async _fetchRecordingSummary(hass: ExtendedHomeAssistant): Promise<void> {
|
||||
const storeRecordingSummary = async (
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<void> => {
|
||||
if (!cameraConfig.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._recordingSummary.set(
|
||||
cameraID,
|
||||
await getRecordingsSummary(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Recording failure should not disrupt the rest of the timeline
|
||||
// experience.
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this._cameras.keys()).map(async (cameraID) => {
|
||||
const cameraConfig = this._cameras.get(cameraID);
|
||||
if (cameraConfig) {
|
||||
await storeRecordingSummary(cameraID, cameraConfig);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events for the timeline.
|
||||
* @param element The element to send error events from.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param start Fetch events that start later than this date.
|
||||
* @param end Fetch events that start earlier than this date.
|
||||
*/
|
||||
protected async _fetchEvents(
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<void> {
|
||||
const params: Map<string, FrigateGetEventsParameters> = new Map();
|
||||
|
||||
this._cameras.forEach((cameraConfig, cameraID) => {
|
||||
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: DATA_MANAGER_MAX_FETCH_COUNT,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let results: Map<string, FrigateEvents>;
|
||||
try {
|
||||
results = await getEventsMultiple(hass, params);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
results.forEach((params, cameraID) => this._addEvents(cameraID, params));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import sortedUniqBy from 'lodash-es/sortedUniqBy';
|
||||
import { RecordingSegment, RecordingSegments } from '../frigate';
|
||||
import { DateRange, MemoryRangeSet } from './data-manager-range';
|
||||
import { DataQuery, QueryResults } from './data-types';
|
||||
|
||||
interface RequestCacheItem<Request, Response> {
|
||||
request: Request;
|
||||
response: Response;
|
||||
expires?: Date;
|
||||
}
|
||||
|
||||
interface DataManagerCache<Request, Response> {
|
||||
get(request: Request): Response | null;
|
||||
has(request: Request): boolean;
|
||||
set(request: Request, response: Response, expiry?: Date): void;
|
||||
}
|
||||
|
||||
export class MemoryRequestCache<Request, Response>
|
||||
implements DataManagerCache<Request, Response>
|
||||
{
|
||||
protected _data: RequestCacheItem<Request, Response>[] = [];
|
||||
|
||||
public get(request: Request): Response | null {
|
||||
const now = this._now();
|
||||
for (const item of this._data) {
|
||||
if (
|
||||
(!item.expires || now <= item.expires) &&
|
||||
this._contains(request, item.request)
|
||||
) {
|
||||
return item.response;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public has(request: Request): boolean {
|
||||
return !!this.get(request);
|
||||
}
|
||||
|
||||
public set(request: Request, response: Response, expiry?: Date): void {
|
||||
this._data.push({
|
||||
request: request,
|
||||
response: response,
|
||||
expires: expiry,
|
||||
});
|
||||
|
||||
// Clean up old requests on set.
|
||||
this._expireOldRequests();
|
||||
}
|
||||
|
||||
protected _now(): Date {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
protected _contains(a: Request, b: Request): boolean {
|
||||
return isEqual(a, b);
|
||||
}
|
||||
|
||||
protected _expireOldRequests(): void {
|
||||
const now = this._now();
|
||||
this._data = this._data.filter((item) => !item.expires || now < item.expires);
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
|
||||
|
||||
export class MemoryRangedCache<Data> {
|
||||
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
|
||||
protected _data: Data[] = [];
|
||||
protected _timeFunc: (data: Data) => number;
|
||||
protected _idFunc: (data: Data) => string;
|
||||
|
||||
constructor(timeFunc: (data: Data) => number, idFunc: (data: Data) => string) {
|
||||
this._timeFunc = timeFunc;
|
||||
this._idFunc = idFunc;
|
||||
}
|
||||
|
||||
public add(range: DateRange, data: Data[]) {
|
||||
this._ranges.add(range);
|
||||
this._data = sortedUniqBy(
|
||||
orderBy(this._data.concat(data), this._timeFunc, 'asc'),
|
||||
this._idFunc,
|
||||
);
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
return this._ranges.hasCoverage(range);
|
||||
}
|
||||
|
||||
public get(range: DateRange): Data[] | null {
|
||||
if (!this.hasCoverage(range)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: Data[] = [];
|
||||
for (const data of this._data) {
|
||||
const start = this._timeFunc(data);
|
||||
if (start > range.start.getTime()) {
|
||||
if (start > range.end.getTime()) {
|
||||
// Data is kept in order.
|
||||
break;
|
||||
}
|
||||
output.push(data);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordingSegmentsCache {
|
||||
protected _segments: Map<string, MemoryRangedCache<RecordingSegment>> = new Map();
|
||||
|
||||
public add(cameraID: string, range: DateRange, segments: RecordingSegments) {
|
||||
let cameraSegmentCache: MemoryRangedCache<RecordingSegment> | undefined =
|
||||
this._segments.get(cameraID);
|
||||
if (!cameraSegmentCache) {
|
||||
cameraSegmentCache = new MemoryRangedCache(
|
||||
(segment: RecordingSegment) => segment.start_time * 1000,
|
||||
(segment: RecordingSegment) => segment.id,
|
||||
);
|
||||
this._segments.set(cameraID, cameraSegmentCache);
|
||||
}
|
||||
cameraSegmentCache.add(range, segments);
|
||||
}
|
||||
|
||||
public hasCoverage(cameraID: string, range: DateRange): boolean {
|
||||
return !!this._segments.get(cameraID)?.hasCoverage(range);
|
||||
}
|
||||
|
||||
public get(cameraID: string, range: DateRange): RecordingSegments | null {
|
||||
return this._segments.get(cameraID)?.get(range) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CameraConfig } from '../../types';
|
||||
import { RecordingSegmentsCache } from './data-manager-cache';
|
||||
import { DataManagerEngine } from './data-manager-engine';
|
||||
import { FrigateDataManagerEngine } from './data-manager-engine-frigate';
|
||||
import { DataQuery } from './data-types';
|
||||
|
||||
export class DataManagerEngineFactory {
|
||||
protected _engines: Map<string, DataManagerEngine> = new Map();
|
||||
|
||||
protected _getOrCreateEngine(engineKey: string): DataManagerEngine | null {
|
||||
const cachedEngine = this._engines.get(engineKey);
|
||||
if (cachedEngine) {
|
||||
return cachedEngine;
|
||||
}
|
||||
let newEngine: DataManagerEngine | null = null;
|
||||
switch (engineKey) {
|
||||
case 'frigate':
|
||||
newEngine = new FrigateDataManagerEngine(new RecordingSegmentsCache());
|
||||
break;
|
||||
}
|
||||
if (newEngine) {
|
||||
this._engines.set(engineKey, newEngine);
|
||||
}
|
||||
return newEngine;
|
||||
}
|
||||
|
||||
public getEngineForQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: DataQuery,
|
||||
): DataManagerEngine | null {
|
||||
const cameraConfig = cameras.get(query.cameraID);
|
||||
return cameraConfig ? this.getEngineForCamera(cameraConfig) : null;
|
||||
}
|
||||
|
||||
public getEngineForCamera(cameraConfig: CameraConfig): DataManagerEngine | null {
|
||||
let engineKey: string | null = null;
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
engineKey = 'frigate';
|
||||
}
|
||||
return engineKey ? this._getOrCreateEngine(engineKey) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import { CAMERA_BIRDSEYE } from '../../const';
|
||||
import { CameraConfig, FrigateRecording } from '../../types';
|
||||
import { MediaQueries, MediaQueriesResults } from '../../view';
|
||||
import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media';
|
||||
import { errorToConsole } from '../basic';
|
||||
import {
|
||||
getEvents,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
NativeFrigateEventQuery,
|
||||
NativeFrigateRecordingSegmentsQuery,
|
||||
RecordingSegments,
|
||||
RecordingSummary,
|
||||
retainEvent,
|
||||
} from '../frigate';
|
||||
import { RecordingSegmentsCache } from './data-manager-cache';
|
||||
import {
|
||||
DataManagerEngine,
|
||||
DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
} from './data-manager-engine';
|
||||
import { DataManagerError } from './data-manager-error';
|
||||
import { DateRange } from './data-manager-range';
|
||||
import {
|
||||
Engine,
|
||||
EventQuery,
|
||||
FrigateEventQueryResults,
|
||||
FrigateRecordingQueryResults,
|
||||
FrigateRecordingSegmentsQueryResults,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingSegmentsQuery,
|
||||
} from './data-types';
|
||||
|
||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
|
||||
class FrigateQueryResultsClassifier {
|
||||
public static isFrigateEventQueryResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateEventQueryResults {
|
||||
return results.engine === Engine.Frigate && results.type === QueryResultsType.Event;
|
||||
}
|
||||
|
||||
public static isFrigateRecordingQueryResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateRecordingQueryResults {
|
||||
return (
|
||||
results.engine === Engine.Frigate && results.type === QueryResultsType.Recording
|
||||
);
|
||||
}
|
||||
|
||||
public static isFrigateRecordingSegmentsResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateRecordingSegmentsQueryResults {
|
||||
return (
|
||||
results.engine === Engine.Frigate &&
|
||||
results.type === QueryResultsType.RecordingSegments
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateDataManagerEngine implements DataManagerEngine {
|
||||
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||
|
||||
constructor(recordingSegmentsCache: RecordingSegmentsCache) {
|
||||
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): string | null {
|
||||
let path: string | null = null;
|
||||
if (ViewMediaClassifier.isFrigateEvent(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/notifications/${media.getID()}/` +
|
||||
`${media.isClip() ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||
`?download=true`;
|
||||
} else if (ViewMediaClassifier.isFrigateRecording(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||
`?download=true`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
query: PartialEventQuery,
|
||||
): EventQuery | null {
|
||||
return {
|
||||
type: QueryType.Event,
|
||||
cameraID: cameraID,
|
||||
...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }),
|
||||
...(cameraConfig.frigate.zone && { zone: cameraConfig.frigate.zone }),
|
||||
...query,
|
||||
};
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQuery(
|
||||
cameraID: string,
|
||||
_cameraConfig: CameraConfig,
|
||||
query: PartialRecordingQuery,
|
||||
): RecordingQuery | null {
|
||||
return {
|
||||
type: QueryType.Recording,
|
||||
cameraID: cameraID,
|
||||
...query,
|
||||
};
|
||||
}
|
||||
|
||||
public generateDefaultRecordingSegmentsQuery(
|
||||
cameraID: string,
|
||||
_cameraConfig: CameraConfig,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery | null {
|
||||
if (!query.start || !query.end) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: QueryType.RecordingSegments,
|
||||
cameraID: cameraID,
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
...query,
|
||||
};
|
||||
}
|
||||
|
||||
public async favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void> {
|
||||
const clientID = cameraConfig.frigate.client_id;
|
||||
if (!ViewMediaClassifier.isFrigateEvent(media)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await retainEvent(hass, clientID, media.getID(cameraConfig), favorite);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
throw new DataManagerError((e as Error).message);
|
||||
}
|
||||
|
||||
media.setFavorite(favorite);
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
): Promise<QueryReturnType<EventQuery> | null> {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
|
||||
if (!cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nativeQuery: NativeFrigateEventQuery = {
|
||||
instance_id: cameraConfig.frigate.client_id,
|
||||
camera: cameraConfig.frigate.camera_name,
|
||||
...(query.what && { label: query.what }),
|
||||
...(query.where && { zone: query.where }),
|
||||
...(query?.end && { before: Math.floor(query.end.getTime() / 1000) }),
|
||||
...(query?.start && { after: Math.floor(query.start.getTime() / 1000) }),
|
||||
...(query?.limit && { limit: query.limit }),
|
||||
...(query?.hasClip && { has_clip: query.hasClip }),
|
||||
...(query?.hasSnapshot && { has_snapshot: query.hasSnapshot }),
|
||||
limit: query?.limit ?? DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
};
|
||||
|
||||
try {
|
||||
const result: FrigateEventQueryResults = {
|
||||
type: QueryResultsType.Event,
|
||||
engine: Engine.Frigate,
|
||||
events: await getEvents(hass, nativeQuery),
|
||||
expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }),
|
||||
};
|
||||
return result;
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
throw new DataManagerError((e as Error).message, query);
|
||||
}
|
||||
}
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
): Promise<QueryReturnType<RecordingQuery> | null> {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
|
||||
if (!cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let recordingSummary: RecordingSummary;
|
||||
try {
|
||||
recordingSummary = await getRecordingsSummary(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
throw new DataManagerError((e as Error).message, query);
|
||||
}
|
||||
|
||||
const recordings: FrigateRecording[] = [];
|
||||
for (const dayData of recordingSummary ?? []) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
if (
|
||||
(!query.start || startHour >= query.start) &&
|
||||
(!query.end || endHour <= query.end)
|
||||
) {
|
||||
recordings.push({
|
||||
camera: cameraConfig.frigate.camera_name,
|
||||
start_time: getUnixTime(startHour),
|
||||
end_time: getUnixTime(endHour),
|
||||
events: hourData.events,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <FrigateRecordingQueryResults>{
|
||||
type: QueryResultsType.Recording,
|
||||
engine: Engine.Frigate,
|
||||
recordings: recordings,
|
||||
expiry: add(new Date(), {
|
||||
seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<QueryReturnType<RecordingSegmentsQuery> | null> {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
|
||||
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const range: DateRange = { start: query.start, end: query.end };
|
||||
|
||||
// A note on Frigate Recording Segments:
|
||||
// - Unlike other query types, there is an internal cache at the engine
|
||||
// level for segments to allow caching "within an existing query" (e.g. if
|
||||
// we already cached hour 1-8, we will avoid a fetch if we request hours
|
||||
// 2-3 even though the query is different -- the segments won't be). This
|
||||
// is since the volume of data in segment transfers can be high, and the
|
||||
// segments can be used in high frequency situations (e.g. video seeking).
|
||||
const cachedSegments = this._recordingSegmentsCache.get(query.cameraID, range);
|
||||
if (cachedSegments) {
|
||||
return {
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
engine: Engine.Frigate,
|
||||
segments: cachedSegments,
|
||||
};
|
||||
}
|
||||
|
||||
const request: NativeFrigateRecordingSegmentsQuery = {
|
||||
instance_id: cameraConfig.frigate.client_id,
|
||||
camera: cameraConfig.frigate.camera_name,
|
||||
after: Math.floor(query.start.getTime() / 1000),
|
||||
before: Math.floor(query.end.getTime() / 1000),
|
||||
};
|
||||
|
||||
let segments: RecordingSegments;
|
||||
try {
|
||||
segments = await getRecordingSegments(hass, request);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
throw new DataManagerError((e as Error).message, query);
|
||||
}
|
||||
|
||||
this._recordingSegmentsCache.add(query.cameraID, range, segments);
|
||||
|
||||
return {
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
engine: Engine.Frigate,
|
||||
segments: segments,
|
||||
};
|
||||
}
|
||||
|
||||
public generateMediaFromEvents(
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const event of results.events) {
|
||||
let mediaType: 'clip' | 'snapshot' | null = null;
|
||||
if (
|
||||
!query.hasClip &&
|
||||
!query.hasSnapshot &&
|
||||
(event.has_clip || event.has_snapshot)
|
||||
) {
|
||||
mediaType = event.has_clip ? 'clip' : 'snapshot';
|
||||
} else if (query.hasSnapshot && event.has_snapshot) {
|
||||
mediaType = 'snapshot';
|
||||
} else if (query.hasClip && event.has_clip) {
|
||||
mediaType = 'clip';
|
||||
}
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
}
|
||||
const media = ViewMediaFactory.createViewMediaFromFrigateEvent(
|
||||
mediaType,
|
||||
query.cameraID,
|
||||
event,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public generateMediaFromRecordings(
|
||||
query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const recording of results.recordings) {
|
||||
const media = ViewMediaFactory.createViewMediaFromFrigateRecording(
|
||||
query.cameraID,
|
||||
recording,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public areMediaQueriesResultsFresh(
|
||||
queries: MediaQueries,
|
||||
results: MediaQueriesResults,
|
||||
): boolean {
|
||||
let freshThreshold: number | null = null;
|
||||
if (queries.areEventQueries()) {
|
||||
freshThreshold = EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
} else if (queries.areRecordingQueries()) {
|
||||
freshThreshold = RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
}
|
||||
const now = new Date();
|
||||
const resultsTimestamp = results.getResultsTimestamp();
|
||||
return (
|
||||
!freshThreshold ||
|
||||
!resultsTimestamp ||
|
||||
add(resultsTimestamp, { seconds: freshThreshold }) >= now
|
||||
);
|
||||
}
|
||||
|
||||
protected _getQueryableCameraConfig(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraID: string,
|
||||
): CameraConfig | null {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) {
|
||||
return null;
|
||||
}
|
||||
return cameraConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../../types';
|
||||
import { MediaQueries, MediaQueriesResults } from '../../view';
|
||||
import { ViewMedia } from '../../view-media';
|
||||
import {
|
||||
EventQuery,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryReturnType,
|
||||
RecordingQuery,
|
||||
RecordingSegmentsQuery,
|
||||
} from './data-types';
|
||||
|
||||
export const DATA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
|
||||
export interface DataManagerEngine {
|
||||
generateDefaultEventQuery(
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
query: PartialEventQuery,
|
||||
): EventQuery | null;
|
||||
|
||||
generateDefaultRecordingQuery(
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
query: PartialRecordingQuery,
|
||||
): RecordingQuery | null;
|
||||
|
||||
generateDefaultRecordingSegmentsQuery(
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery | null;
|
||||
|
||||
getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
): Promise<QueryReturnType<EventQuery> | null>;
|
||||
|
||||
getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
): Promise<QueryReturnType<RecordingQuery> | null>;
|
||||
|
||||
getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<QueryReturnType<RecordingSegmentsQuery> | null>;
|
||||
|
||||
generateMediaFromEvents(
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
generateMediaFromRecordings(
|
||||
query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
|
||||
|
||||
favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
areMediaQueriesResultsFresh(
|
||||
queries: MediaQueries,
|
||||
results: MediaQueriesResults,
|
||||
): boolean;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FrigateCardError } from '../../types';
|
||||
|
||||
export class DataManagerError extends FrigateCardError {}
|
||||
@@ -0,0 +1,84 @@
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
|
||||
interface Range<T extends Date | number> {
|
||||
start: T;
|
||||
end: T;
|
||||
}
|
||||
|
||||
export type DateRange = Range<Date>;
|
||||
|
||||
export class MemoryRangeSet {
|
||||
protected _ranges: DateRange[];
|
||||
|
||||
constructor(ranges?: DateRange[]) {
|
||||
this._ranges = ranges ?? [];
|
||||
}
|
||||
|
||||
public clone(): MemoryRangeSet {
|
||||
return new MemoryRangeSet(cloneDeep(this._ranges));
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
return this._ranges.some((cachedRange) =>
|
||||
this._isEntirelyContained(cachedRange, range),
|
||||
);
|
||||
}
|
||||
|
||||
public add(range: DateRange): void {
|
||||
this._ranges.push(range);
|
||||
this._ranges = compressRanges(this._ranges);
|
||||
}
|
||||
|
||||
protected _isEntirelyContained(bigger: DateRange, smaller: DateRange): boolean {
|
||||
return smaller.start >= bigger.start && smaller.end <= bigger.end;
|
||||
}
|
||||
}
|
||||
|
||||
export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
|
||||
return (
|
||||
// a starts within the range of b.
|
||||
(a.start >= b.start && a.start <= b.end) ||
|
||||
// a events within the range of b.
|
||||
(a.end >= b.start && a.end <= b.end) ||
|
||||
// a encompasses the entire range of b.
|
||||
(a.start <= b.start && a.end >= b.end)
|
||||
);
|
||||
}
|
||||
|
||||
export const compressRanges = <T extends Date | number>(
|
||||
ranges: Range<T>[],
|
||||
toleranceSeconds = 0,
|
||||
): Range<T>[] => {
|
||||
const compressedRanges: Range<T>[] = [];
|
||||
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||
|
||||
let current: Range<T> | null = null;
|
||||
for (let i = 0; i < ranges.length; ++i) {
|
||||
const item = ranges[i];
|
||||
const itemStartSeconds =
|
||||
item.start instanceof Date ? item.start.getTime() : item.start;
|
||||
|
||||
if (!current) {
|
||||
current = { ...item };
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentEndSeconds =
|
||||
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
||||
|
||||
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
|
||||
if (item.end > current.end) {
|
||||
current.end = item.end;
|
||||
}
|
||||
} else {
|
||||
compressedRanges.push(current);
|
||||
current = { ...item };
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
compressedRanges.push(current);
|
||||
}
|
||||
|
||||
return compressedRanges;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfMinute from 'date-fns/endOfMinute';
|
||||
import endOfWeek from 'date-fns/endOfWeek';
|
||||
import startOfWeek from 'date-fns/startOfWeek';
|
||||
import { DateRange } from './data-manager-range';
|
||||
|
||||
export const convertRangeToCacheFriendlyTimes = (
|
||||
range: DateRange,
|
||||
options?: {
|
||||
endCap?: boolean;
|
||||
},
|
||||
): DateRange => {
|
||||
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
|
||||
let cacheableStart: Date;
|
||||
let cacheableEnd: Date;
|
||||
|
||||
if (widthSeconds <= 60 * 60) {
|
||||
cacheableStart = startOfHour(range.start);
|
||||
cacheableEnd = endOfHour(range.end);
|
||||
} else if (widthSeconds <= 60 * 60 * 24) {
|
||||
cacheableStart = startOfDay(range.start);
|
||||
cacheableEnd = endOfDay(range.end);
|
||||
} else {
|
||||
cacheableStart = startOfWeek(range.start);
|
||||
cacheableEnd = endOfWeek(range.end);
|
||||
}
|
||||
|
||||
if (options?.endCap) {
|
||||
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
|
||||
}
|
||||
|
||||
return {
|
||||
start: cacheableStart,
|
||||
end: cacheableEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export const capEndDate = (end: Date): Date => {
|
||||
const now = new Date();
|
||||
return end > now ? now : end;
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../../types.js';
|
||||
import { arrayify, setify } from '../basic.js';
|
||||
import {
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
PartialDataQuery,
|
||||
PartialEventQuery,
|
||||
PartialQueryConcreteType,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResults,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResults,
|
||||
} from './data-types.js';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { DataManagerEngineFactory } from './data-manager-engine-factory.js';
|
||||
import { ViewMedia } from '../../view-media.js';
|
||||
import { MediaQueries, MediaQueriesResults } from '../../view.js';
|
||||
import { MemoryRequestCache } from './data-manager-cache.js';
|
||||
|
||||
export class QueryClassifier {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
return query.type === QueryType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
): query is RecordingQuery {
|
||||
return query.type === QueryType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
): query is RecordingSegmentsQuery {
|
||||
return query.type === QueryType.RecordingSegments;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryResultClassifier {
|
||||
public static isEventQueryResult(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is EventQueryResults {
|
||||
return queryResults.type === QueryResultsType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingQueryResults {
|
||||
return queryResults.type === QueryResultsType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingSegmentsQueryResults {
|
||||
return queryResults.type === QueryResultsType.RecordingSegments;
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestCache = MemoryRequestCache<DataQuery, QueryResults>;
|
||||
|
||||
export class DataManager {
|
||||
protected _engineFactory: DataManagerEngineFactory;
|
||||
protected _cameras: Map<string, CameraConfig>;
|
||||
protected _requestCache: RequestCache;
|
||||
|
||||
constructor(
|
||||
engineFactory: DataManagerEngineFactory,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
requestCache: RequestCache,
|
||||
) {
|
||||
this._engineFactory = engineFactory;
|
||||
this._cameras = cameras;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
|
||||
public generateDefaultEventQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PartialEventQuery,
|
||||
): EventQuery[] {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
...partialQuery,
|
||||
type: QueryType.Event,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PartialRecordingQuery,
|
||||
): RecordingQuery[] {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
...partialQuery,
|
||||
type: QueryType.Recording,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultRecordingSegmentsQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
...partialQuery,
|
||||
type: QueryType.RecordingSegments,
|
||||
});
|
||||
}
|
||||
|
||||
protected _generateDefaultQueries<PQT extends Partial<DataQuery>>(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PQT,
|
||||
): PartialQueryConcreteType<PQT>[] {
|
||||
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
||||
const _cameraIDs = setify(cameraIDs);
|
||||
|
||||
_cameraIDs.forEach((cameraID) => {
|
||||
const cameraConfig = this._cameras.get(cameraID);
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (!engine) {
|
||||
return;
|
||||
}
|
||||
|
||||
let query: DataQuery | null = null;
|
||||
if (QueryClassifier.isEventQuery(partialQuery)) {
|
||||
query = engine.generateDefaultEventQuery(cameraID, cameraConfig, partialQuery);
|
||||
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
||||
query = engine.generateDefaultRecordingQuery(
|
||||
cameraID,
|
||||
cameraConfig,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||
query = engine.generateDefaultRecordingSegmentsQuery(
|
||||
cameraID,
|
||||
cameraConfig,
|
||||
partialQuery,
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
|
||||
}
|
||||
});
|
||||
return concreteQueries;
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
query: EventQuery | EventQuery[],
|
||||
): Promise<Map<EventQuery, EventQueryResults>> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingQuery | RecordingQuery[],
|
||||
): Promise<Map<RecordingQuery, RecordingQueryResults>> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
|
||||
): Promise<Map<RecordingSegmentsQuery, RecordingSegmentsQueryResults>> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async executeMediaQuery(
|
||||
hass: HomeAssistant,
|
||||
mediaQuerys: MediaQueries,
|
||||
): Promise<MediaQueriesResults | null> {
|
||||
const queries: (RecordingQuery | EventQuery)[] | null = mediaQuerys.getQueries();
|
||||
if (!queries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = await this._handleQuery(hass, queries);
|
||||
|
||||
const mediaArray: ViewMedia[] = [];
|
||||
for (const [query, result] of results.entries()) {
|
||||
const engine = this._engineFactory.getEngineForQuery(this._cameras, query);
|
||||
if (engine) {
|
||||
let media: ViewMedia[] | null = null;
|
||||
if (
|
||||
QueryClassifier.isEventQuery(query) &&
|
||||
QueryResultClassifier.isEventQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromEvents(query, result);
|
||||
} else if (
|
||||
QueryClassifier.isRecordingQuery(query) &&
|
||||
QueryResultClassifier.isRecordingQuery(result)
|
||||
) {
|
||||
media = engine.generateMediaFromRecordings(query, result);
|
||||
}
|
||||
if (media) {
|
||||
mediaArray.push(...media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediaArray.length
|
||||
? new MediaQueriesResults(
|
||||
orderBy(mediaArray, (media) => media.getStartTime(), 'desc'),
|
||||
// Select the first (most-recent) item.
|
||||
0,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(media: ViewMedia): string | null {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
const engine = cameraConfig
|
||||
? this._engineFactory.getEngineForCamera(cameraConfig)
|
||||
: null;
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getMediaDownloadPath(cameraConfig, media);
|
||||
}
|
||||
|
||||
public async favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void> {
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (engine) {
|
||||
engine.favoriteMedia(hass, cameraConfig, media, favorite);
|
||||
}
|
||||
}
|
||||
|
||||
public areMediaQueriesResultsFresh(
|
||||
queries: MediaQueries,
|
||||
results: MediaQueriesResults,
|
||||
): boolean {
|
||||
const cameraIDs: Set<string> = new Set();
|
||||
(queries.getQueries() ?? []).forEach((query) => cameraIDs.add(query.cameraID));
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = this._cameras.get(cameraID);
|
||||
if (!cameraConfig) {
|
||||
return false;
|
||||
}
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (!engine || !engine.areMediaQueriesResultsFresh(queries, results)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async _handleQuery<QT extends DataQuery>(
|
||||
hass: HomeAssistant,
|
||||
query: QT | QT[],
|
||||
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||
const _queries = arrayify(query);
|
||||
const results = new Map<QT, QueryReturnType<QT>>();
|
||||
|
||||
const queryStartTime = new Date();
|
||||
let queryCachedCount = 0;
|
||||
|
||||
const processQuery = async (query: QT): Promise<void> => {
|
||||
const cachedResult: QueryReturnType<QT> | null = this._requestCache.get(
|
||||
query,
|
||||
) as QueryReturnType<QT> | null;
|
||||
if (cachedResult) {
|
||||
queryCachedCount++;
|
||||
results.set(query, cachedResult);
|
||||
return;
|
||||
}
|
||||
|
||||
const engine = this._engineFactory.getEngineForQuery(this._cameras, query);
|
||||
if (!engine) {
|
||||
return;
|
||||
}
|
||||
|
||||
let result: QueryResults | null = null;
|
||||
if (QueryClassifier.isEventQuery(query)) {
|
||||
result = await engine.getEvents(hass, this._cameras, query);
|
||||
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||
result = await engine.getRecordings(hass, this._cameras, query);
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||
result = await engine.getRecordingSegments(hass, this._cameras, query);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
if (result.expiry) {
|
||||
this._requestCache.set(query, result, result.expiry);
|
||||
}
|
||||
results.set(query, result as QueryReturnType<QT>);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(_queries.map((query) => processQuery(query)));
|
||||
|
||||
console.debug(
|
||||
'Frigate Card DataManager request (Cached:',
|
||||
`${queryCachedCount}/${_queries.length},`,
|
||||
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
'Queries:',
|
||||
_queries,
|
||||
', Results:',
|
||||
results,
|
||||
')',
|
||||
);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FrigateEvents, FrigateRecording } from '../../types';
|
||||
import { RecordingSegments } from '../frigate';
|
||||
|
||||
// ====
|
||||
// Base
|
||||
// ====
|
||||
|
||||
export enum QueryType {
|
||||
Event = 'event-query',
|
||||
Recording = 'recording-query',
|
||||
RecordingSegments = 'recording-segments-query',
|
||||
}
|
||||
|
||||
export enum QueryResultsType {
|
||||
Event = 'event-results',
|
||||
Recording = 'recording-results',
|
||||
RecordingSegments = 'recording-segments-results',
|
||||
}
|
||||
|
||||
export enum Engine {
|
||||
Frigate = 'frigate',
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
type: QueryType;
|
||||
cameraID: string;
|
||||
}
|
||||
export type PartialDataQuery = Partial<DataQuery>;
|
||||
|
||||
export interface TimeBasedDataQuery {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
export interface LimitedDataQuery {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface MediaQuery
|
||||
extends DataQuery,
|
||||
Partial<TimeBasedDataQuery>,
|
||||
Partial<LimitedDataQuery> {}
|
||||
|
||||
export interface QueryResults {
|
||||
type: QueryResultsType;
|
||||
engine: Engine;
|
||||
expiry?: Date;
|
||||
}
|
||||
|
||||
export type QueryReturnType<QT> = QT extends EventQuery
|
||||
? EventQueryResults
|
||||
: QT extends RecordingQuery
|
||||
? RecordingQueryResults
|
||||
: QT extends RecordingSegmentsQuery
|
||||
? RecordingSegmentsQueryResults
|
||||
: never;
|
||||
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
||||
? EventQuery
|
||||
: PQT extends PartialRecordingQuery
|
||||
? RecordingQuery
|
||||
: PQT extends PartialRecordingSegmentsQuery
|
||||
? RecordingSegmentsQuery
|
||||
: never;
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
|
||||
export interface EventQuery extends MediaQuery {
|
||||
type: QueryType.Event;
|
||||
|
||||
// Frigate equivalent: has_snapshot
|
||||
hasSnapshot?: boolean;
|
||||
|
||||
// Frigate equivalent: has_clip
|
||||
hasClip?: boolean;
|
||||
|
||||
// Frigate equivalent: label
|
||||
what?: string;
|
||||
|
||||
// Frigate equivalent: zone
|
||||
where?: string;
|
||||
}
|
||||
export type PartialEventQuery = Partial<EventQuery>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface EventQueryResults extends QueryResults {
|
||||
type: QueryResultsType.Event;
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Recording Query
|
||||
// ===============
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface RecordingQuery extends MediaQuery {
|
||||
type: QueryType.Recording;
|
||||
}
|
||||
export type PartialRecordingQuery = Partial<RecordingQuery>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface RecordingQueryResults extends QueryResults {
|
||||
type: QueryResultsType.Recording;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Recording Segments Query
|
||||
// ========================
|
||||
|
||||
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
|
||||
type: QueryType.RecordingSegments;
|
||||
}
|
||||
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
||||
//export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery> & { type: QueryType.RecordingSegments };
|
||||
|
||||
export interface RecordingSegmentsQueryResults extends QueryResults {
|
||||
type: QueryResultsType.RecordingSegments;
|
||||
segments: RecordingSegments;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Frigate concrete results
|
||||
// ========================
|
||||
|
||||
export interface FrigateEventQueryResults extends EventQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
events: FrigateEvents;
|
||||
}
|
||||
|
||||
export interface FrigateRecordingQueryResults extends RecordingQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
recordings: FrigateRecording[];
|
||||
}
|
||||
|
||||
export interface FrigateRecordingSegmentsQueryResults
|
||||
extends RecordingSegmentsQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
}
|
||||
+35
-80
@@ -1,19 +1,15 @@
|
||||
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 {
|
||||
BrowseRecordingQueryParameters,
|
||||
ClipsOrSnapshots,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
FrigateEvents,
|
||||
frigateEventsSchema,
|
||||
FrigateRecording,
|
||||
} from '../types';
|
||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||
import { homeAssistantWSRequest } from './ha';
|
||||
@@ -63,6 +59,8 @@ const recordingSegmentSchema = z.object({
|
||||
end_time: z.number(),
|
||||
id: z.string(),
|
||||
});
|
||||
export type RecordingSegment = z.infer<typeof recordingSegmentSchema>;
|
||||
|
||||
const recordingSegmentsSchema = recordingSegmentSchema.array();
|
||||
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
|
||||
|
||||
@@ -80,7 +78,7 @@ export type RetainResult = z.infer<typeof retainResultSchema>;
|
||||
* @returns A RecordingSummary object.
|
||||
*/
|
||||
export const getRecordingsSummary = async (
|
||||
hass: ExtendedHomeAssistant,
|
||||
hass: HomeAssistant,
|
||||
client_id: string,
|
||||
camera_name: string,
|
||||
): Promise<RecordingSummary> => {
|
||||
@@ -96,31 +94,29 @@ export const getRecordingsSummary = async (
|
||||
);
|
||||
};
|
||||
|
||||
export interface NativeFrigateRecordingSegmentsQuery {
|
||||
instance_id: string;
|
||||
camera: string;
|
||||
after: number;
|
||||
before: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recording segments. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param client_id The Frigate client_id.
|
||||
* @param camera_name The Frigate camera name.
|
||||
* @param before The segment low watermark.
|
||||
* @param after The segment high watermark.
|
||||
* @param params The recording segment query parameters.
|
||||
* @returns A RecordingSegments object.
|
||||
*/
|
||||
export const getRecordingSegments = async (
|
||||
hass: ExtendedHomeAssistant,
|
||||
client_id: string,
|
||||
camera_name: string,
|
||||
before: Date,
|
||||
after: Date,
|
||||
hass: HomeAssistant,
|
||||
params: NativeFrigateRecordingSegmentsQuery,
|
||||
): Promise<RecordingSegments> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
recordingSegmentsSchema,
|
||||
{
|
||||
type: 'frigate/recordings/get',
|
||||
instance_id: client_id,
|
||||
camera: camera_name,
|
||||
before: Math.floor(before.getTime() / 1000),
|
||||
after: Math.ceil(after.getTime() / 1000),
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -159,7 +155,7 @@ export async function retainEvent(
|
||||
}
|
||||
}
|
||||
|
||||
export interface FrigateGetEventsParameters {
|
||||
export interface NativeFrigateEventQuery {
|
||||
instance_id?: string;
|
||||
camera?: string;
|
||||
label?: string;
|
||||
@@ -179,7 +175,7 @@ export interface FrigateGetEventsParameters {
|
||||
*/
|
||||
export const getEvents = async (
|
||||
hass: HomeAssistant,
|
||||
params?: FrigateGetEventsParameters,
|
||||
params?: NativeFrigateEventQuery,
|
||||
): Promise<FrigateEvents> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
@@ -192,29 +188,6 @@ export const getEvents = async (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -233,6 +206,12 @@ export const getEventTitle = (event: FrigateEvent): string => {
|
||||
)}%]`;
|
||||
};
|
||||
|
||||
export const getRecordingTitle = (recording: FrigateRecording): string => {
|
||||
return `${prettifyTitle(recording.camera)} ${formatDateAndTime(
|
||||
fromUnixTime(recording.start_time),
|
||||
)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a thumbnail URL for an event.
|
||||
* @param clientId The Frigate client id.
|
||||
@@ -254,10 +233,10 @@ export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): str
|
||||
export const getEventMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
id: string,
|
||||
event: FrigateEvent,
|
||||
mediaType: ClipsOrSnapshots,
|
||||
): string => {
|
||||
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${id}`;
|
||||
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -267,43 +246,19 @@ export const getEventMediaContentID = (
|
||||
* @returns A recording identifier.
|
||||
*/
|
||||
export const getRecordingMediaContentID = (
|
||||
params: BrowseRecordingQueryParameters,
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
recording: FrigateRecording,
|
||||
): string => {
|
||||
const date = fromUnixTime(recording.start_time);
|
||||
return [
|
||||
'media-source://frigate',
|
||||
params.clientId,
|
||||
clientId,
|
||||
'recordings',
|
||||
`${params.year}-${String(params.month).padStart(2, '0')}`,
|
||||
String(params.day).padStart(2, '0'),
|
||||
String(params.hour).padStart(2, '0'),
|
||||
params.cameraName,
|
||||
cameraName,
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
)}`,
|
||||
String(date.getHours()).padStart(2, '0'),
|
||||
].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;
|
||||
}
|
||||
|
||||
+12
-117
@@ -1,11 +1,6 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { ViewContext } from 'view';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import {
|
||||
dispatchErrorMessageEvent,
|
||||
dispatchFrigateCardErrorEvent,
|
||||
dispatchMessageEvent,
|
||||
} from '../../components/message.js';
|
||||
import { dispatchErrorMessageEvent } from '../../components/message.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
@@ -14,7 +9,6 @@ import {
|
||||
ClipsOrSnapshots,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateBrowseMediaSourceSchema,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
FrigateRecording,
|
||||
MEDIA_CLASS_PLAYLIST,
|
||||
@@ -22,7 +16,6 @@ import {
|
||||
MEDIA_TYPE_PLAYLIST,
|
||||
MEDIA_TYPE_VIDEO,
|
||||
} from '../../types.js';
|
||||
import { View } from '../../view.js';
|
||||
import { getAllDependentCameras, getCameraTitle } from '../camera.js';
|
||||
|
||||
/**
|
||||
@@ -119,7 +112,7 @@ const browseMediaQuery = async (
|
||||
if (params.cameraID) {
|
||||
result.children?.forEach((child: FrigateBrowseMediaSource) => {
|
||||
(child.frigate ??= {}).cameraID = params.cameraID;
|
||||
})
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -185,7 +178,10 @@ export const mergeFrigateBrowseMediaSources = async (
|
||||
}
|
||||
}
|
||||
|
||||
return createEventParentForChildren('Merged events', children.sort(sortYoungestToOldest));
|
||||
return createEventParentForChildren(
|
||||
'Merged events',
|
||||
children.sort(sortYoungestToOldest),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -290,81 +286,6 @@ export const getFullDependentBrowseMediaQueryParametersOrDispatchError = (
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the latest media and dispatch a change view event to reflect the
|
||||
* results. If no media is found a suitable message event will be triggered
|
||||
* instead.
|
||||
* @param element The HTMLElement to dispatch events from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param view The current view to evolve.
|
||||
* @param browseMediaQueryParameters The media parameters to query with.
|
||||
* @returns
|
||||
*/
|
||||
export const fetchLatestMediaAndDispatchViewChange = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
view: Readonly<View>,
|
||||
browseMediaQueryParameters: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
|
||||
): Promise<void> => {
|
||||
let parent: FrigateBrowseMediaSource | null;
|
||||
try {
|
||||
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
const childIndex = getFirstTrueMediaChildIndex(parent);
|
||||
if (!parent || !parent.children || childIndex == null) {
|
||||
return dispatchMessageEvent(
|
||||
element,
|
||||
view.isClipRelatedView()
|
||||
? localize('common.no_clip')
|
||||
: localize('common.no_snapshot'),
|
||||
'info',
|
||||
{
|
||||
icon: view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
view
|
||||
.evolve({
|
||||
target: parent,
|
||||
childIndex: childIndex,
|
||||
})
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change
|
||||
* view event to reflect the results.
|
||||
* @param node The HTMLElement to dispatch events from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param view The current view to evolve.
|
||||
* @param child The FrigateBrowseMediaSource child to query for.
|
||||
* @returns
|
||||
*/
|
||||
export const fetchChildMediaAndDispatchViewChange = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
view: Readonly<View>,
|
||||
child: Readonly<FrigateBrowseMediaSource>,
|
||||
context?: ViewContext,
|
||||
): Promise<void> => {
|
||||
let parent: FrigateBrowseMediaSource;
|
||||
try {
|
||||
parent = await browseMedia(hass, child.media_content_id);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
|
||||
view
|
||||
.evolve({
|
||||
target: parent,
|
||||
})
|
||||
.mergeInContext(context)
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an array of media children, create a parent for them.
|
||||
* @param title The title to use for the parent.
|
||||
@@ -402,7 +323,7 @@ export const createChild = (
|
||||
thumbnail?: string;
|
||||
recording?: FrigateRecording;
|
||||
event?: FrigateEvent;
|
||||
cameraID?: string,
|
||||
cameraID?: string;
|
||||
},
|
||||
): FrigateBrowseMediaSource => {
|
||||
const result: FrigateBrowseMediaSource = {
|
||||
@@ -413,10 +334,10 @@ export const createChild = (
|
||||
can_play: true,
|
||||
can_expand: false,
|
||||
thumbnail: options?.thumbnail ?? null,
|
||||
children: null
|
||||
}
|
||||
children: null,
|
||||
};
|
||||
if (options?.recording || options?.cameraID || options?.event) {
|
||||
result.frigate = {}
|
||||
result.frigate = {};
|
||||
if (options?.event) {
|
||||
result.frigate.event = options.event;
|
||||
}
|
||||
@@ -443,38 +364,12 @@ export const sortYoungestToOldest = (
|
||||
const a_source = a.frigate?.event ?? a.frigate?.recording;
|
||||
const b_source = b.frigate?.event ?? b.frigate?.recording;
|
||||
|
||||
if (
|
||||
!a_source ||
|
||||
(b_source && b_source.start_time > a_source.start_time)
|
||||
) {
|
||||
if (!a_source || (b_source && b_source.start_time > a_source.start_time)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (
|
||||
!b_source ||
|
||||
(a_source && b_source.start_time < a_source.start_time)
|
||||
) {
|
||||
if (!b_source || (a_source && b_source.start_time < a_source.start_time)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
/**
|
||||
* 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.cameraName,
|
||||
`${params.year}-${String(params.month).padStart(2, '0')}-${String(
|
||||
params.day,
|
||||
).padStart(2, '0')}`,
|
||||
String(params.hour).padStart(2, '0'),
|
||||
].join('/');
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import {
|
||||
FrigateBrowseMediaSource,
|
||||
ResolvedMedia,
|
||||
resolvedMediaSchema,
|
||||
} from '../../types.js';
|
||||
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
|
||||
import { errorToConsole } from '../basic';
|
||||
|
||||
// It's important the cache size be at least as large as the largest likely
|
||||
@@ -53,25 +49,22 @@ export class ResolvedMediaCache {
|
||||
/**
|
||||
* Resolve a given media source item.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param mediaSource The media source object.
|
||||
* @param mediaContentID The media content ID.
|
||||
* @param cache An optional ResolvedMediaCache object.
|
||||
* @returns The resolved media or `null`.
|
||||
*/
|
||||
export const resolveMedia = async (
|
||||
hass: HomeAssistant,
|
||||
mediaSource?: FrigateBrowseMediaSource,
|
||||
mediaContentID: string,
|
||||
cache?: ResolvedMediaCache,
|
||||
): Promise<ResolvedMedia | null> => {
|
||||
if (!mediaSource) {
|
||||
return null;
|
||||
}
|
||||
const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
|
||||
const cachedValue = cache ? cache.get(mediaContentID) : undefined;
|
||||
if (cachedValue) {
|
||||
return cachedValue;
|
||||
}
|
||||
const request = {
|
||||
type: 'media_source/resolve_media',
|
||||
media_content_id: mediaSource.media_content_id,
|
||||
media_content_id: mediaContentID,
|
||||
};
|
||||
let resolvedMedia: ResolvedMedia | null = null;
|
||||
try {
|
||||
@@ -80,7 +73,7 @@ export const resolveMedia = async (
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
if (cache && resolvedMedia) {
|
||||
cache.set(mediaSource.media_content_id, resolvedMedia);
|
||||
cache.set(mediaContentID, resolvedMedia);
|
||||
}
|
||||
return resolvedMedia;
|
||||
};
|
||||
|
||||
+175
-190
@@ -1,27 +1,73 @@
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import sub from 'date-fns/sub';
|
||||
import { ViewContext } from 'view';
|
||||
import { dispatchMessageEvent } from '../components/message';
|
||||
import { localize } from '../localize/localize';
|
||||
import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types';
|
||||
import { View } from '../view';
|
||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||
import { getRecordingMediaContentID } from './frigate';
|
||||
import {
|
||||
createChild,
|
||||
createEventParentForChildren,
|
||||
sortYoungestToOldest,
|
||||
} from './ha/browse-media';
|
||||
import {
|
||||
RecordingSegmentsItem,
|
||||
sortOldestToYoungest,
|
||||
DataManager,
|
||||
} from './data-manager';
|
||||
import { getAllDependentCameras, getTrueCameras } from './camera.js';
|
||||
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||
import { EventMediaQueries, RecordingMediaQueries, View } from '../view';
|
||||
import { RecordingSegments } from './frigate';
|
||||
import { DataManager } from './data/data-manager';
|
||||
import { getAllDependentCameras } from './camera.js';
|
||||
import { ViewMedia, ViewMediaClassifier } from '../view-media';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
dataManager: DataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
targetView?: FrigateCardView;
|
||||
},
|
||||
): Promise<void> => {
|
||||
(
|
||||
await createViewForEvents(hass, dataManager, cameras, view, {
|
||||
...options,
|
||||
limit: 50, // Capture the 50 most recent events.
|
||||
})
|
||||
).dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
export const createViewForEvents = async (
|
||||
hass: HomeAssistant,
|
||||
dataManager: DataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
query?: EventMediaQueries;
|
||||
cameraIDs?: Set<string>;
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
targetView?: FrigateCardView;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<View> => {
|
||||
let query: EventMediaQueries;
|
||||
if (options?.query) {
|
||||
query = options.query;
|
||||
} else {
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||
|
||||
const queries = dataManager.generateDefaultEventQueries(cameraIDs, {
|
||||
...(options?.limit && { limit: options.limit }),
|
||||
...((!options?.mediaType || ['clips', 'all'].includes(options.mediaType)) && {
|
||||
has_clip: true,
|
||||
}),
|
||||
...(options?.mediaType === 'snapshots' && { has_snapshot: true }),
|
||||
});
|
||||
query = new EventMediaQueries(queries);
|
||||
}
|
||||
const queryResults = await dataManager.executeMediaQuery(hass, query);
|
||||
|
||||
return view?.evolve({
|
||||
view: options?.targetView,
|
||||
query: query,
|
||||
queryResults: queryResults,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the view to a recent recording.
|
||||
@@ -34,7 +80,7 @@ import { getAllDependentCameras, getTrueCameras } from './camera.js';
|
||||
*/
|
||||
export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
hass: HomeAssistant,
|
||||
dataManager: DataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
@@ -43,20 +89,19 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
},
|
||||
): Promise<void> => {
|
||||
const now = new Date();
|
||||
|
||||
await changeViewToRecording(element, hass, dataManager, cameras, view, {
|
||||
...options,
|
||||
|
||||
// Fetch 1 days worth of recordings (including recordings that are for the current hour).
|
||||
cameraIDs: getAllDependentCameras(cameras, view.camera),
|
||||
start: sub(now, { days: 1 }),
|
||||
end: add(now, { hours: 1 }),
|
||||
});
|
||||
(
|
||||
await createViewForRecordings(hass, dataManager, cameras, view, {
|
||||
...options,
|
||||
// Fetch 7 days worth of recordings (including recordings that are for the
|
||||
// current hour).
|
||||
start: sub(now, { days: 7 }),
|
||||
end: add(now, { hours: 1 }),
|
||||
})
|
||||
).dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the view to a recording.
|
||||
* @param element The element to dispatch the view change from.
|
||||
* Create a view for recordings.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
@@ -65,9 +110,8 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
* targetTime to seek to, a targetView to dispatch to and a set of cameraIDs to
|
||||
* restrict to.
|
||||
*/
|
||||
export const changeViewToRecording = async (
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
export const createViewForRecordings = async (
|
||||
hass: HomeAssistant,
|
||||
dataManager: DataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
@@ -78,165 +122,104 @@ export const changeViewToRecording = async (
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (options && options.start && options.end) {
|
||||
await dataManager.fetchIfNecessary(element, hass, options.start, options.end);
|
||||
}
|
||||
|
||||
): Promise<View> => {
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
: new Set([view.camera]);
|
||||
const children = createRecordingChildren(dataManager, cameras, cameraIDs, {
|
||||
...(options?.start && options?.end && { start: options.start, end: options.end }),
|
||||
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||
|
||||
const queries = dataManager.generateDefaultRecordingQueries(cameraIDs, {
|
||||
...(options?.start && { start: options.start }),
|
||||
...(options?.end && { end: options.end }),
|
||||
});
|
||||
|
||||
if (!children.length) {
|
||||
return dispatchMessageEvent(element, localize('common.no_recording'), 'info', {
|
||||
icon: 'mdi:album',
|
||||
});
|
||||
const query = new RecordingMediaQueries(queries);
|
||||
const queryResults = await dataManager.executeMediaQuery(hass, query);
|
||||
|
||||
let viewerContext: ViewContext | undefined = {};
|
||||
const mediaArray = queryResults?.getResults();
|
||||
if (queryResults && mediaArray && options?.targetTime) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
findClosestMediaIndex(media, options.targetTime as Date, cameraIDs),
|
||||
);
|
||||
viewerContext = await generateMediaViewerContext(
|
||||
hass,
|
||||
dataManager,
|
||||
mediaArray,
|
||||
options.targetTime,
|
||||
);
|
||||
}
|
||||
|
||||
const viewerContext = options?.targetTime
|
||||
? generateMediaViewerContextForChildren(dataManager, children, options.targetTime)
|
||||
: {};
|
||||
const childIndex = options?.targetTime
|
||||
? findChildIndex(children, options.targetTime, cameraIDs)
|
||||
: null;
|
||||
const child = childIndex !== null ? children[childIndex] ?? null : null;
|
||||
|
||||
view
|
||||
?.evolve({
|
||||
view: options?.targetView ? options.targetView : 'recording',
|
||||
target: createEventParentForChildren(localize('common.recordings'), children),
|
||||
childIndex: childIndex ?? 0,
|
||||
...(child?.frigate?.cameraID && { camera: child.frigate?.cameraID }),
|
||||
})
|
||||
.mergeInContext(viewerContext)
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create recording objects.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
* @param cameraIDs The camera IDs to include recordings for.
|
||||
* @param options A specific window (start and end) to allow recordings for.
|
||||
* @returns
|
||||
*/
|
||||
const createRecordingChildren = (
|
||||
dataManager: DataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
options?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
},
|
||||
): FrigateBrowseMediaSource[] => {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
|
||||
for (const cameraID of getTrueCameras(cameras, cameraIDs)) {
|
||||
const config = cameras.get(cameraID) ?? null;
|
||||
const recordingSummary = dataManager.getRecordingSummaryForCamera(cameraID);
|
||||
if (!config?.frigate.camera_name || !recordingSummary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dayData of recordingSummary) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
|
||||
if (
|
||||
(!options?.start || startHour >= options.start) &&
|
||||
(!options?.end || endHour <= options.end)
|
||||
) {
|
||||
children.push(
|
||||
createChild(
|
||||
`${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`,
|
||||
getRecordingMediaContentID({
|
||||
clientId: config.frigate.client_id,
|
||||
year: dayData.day.getFullYear(),
|
||||
month: dayData.day.getMonth() + 1,
|
||||
day: dayData.day.getDate(),
|
||||
hour: hourData.hour,
|
||||
cameraName: config.frigate.camera_name,
|
||||
}),
|
||||
{
|
||||
recording: {
|
||||
camera: config.frigate.camera_name,
|
||||
start_time: getUnixTime(startHour),
|
||||
end_time: getUnixTime(endHour),
|
||||
events: hourData.events,
|
||||
},
|
||||
cameraID: cameraID,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort the events by time (to align recordings for different cameras at the
|
||||
// same time).
|
||||
return children.sort(sortYoungestToOldest);
|
||||
return (
|
||||
view
|
||||
?.evolve({
|
||||
view: options?.targetView ? options.targetView : 'recording',
|
||||
query: query,
|
||||
queryResults: queryResults,
|
||||
})
|
||||
.mergeInContext(viewerContext) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the media view context for a set of media children (used to set
|
||||
* seek times into each media item).
|
||||
* @param hass The Home Assistant object.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param children The media children.
|
||||
* @param media The media.
|
||||
* @param targetTime The target time.
|
||||
* @returns The ViewContext.
|
||||
*/
|
||||
export const generateMediaViewerContextForChildren = (
|
||||
export const generateMediaViewerContext = async (
|
||||
hass: HomeAssistant,
|
||||
dataManager: DataManager,
|
||||
children: FrigateBrowseMediaSource[],
|
||||
media: ViewMedia[],
|
||||
targetTime: Date,
|
||||
): ViewContext => {
|
||||
): Promise<ViewContext> => {
|
||||
const seek = new Map();
|
||||
const segmentsDataset = dataManager.recordingSegments;
|
||||
const hourStart = startOfHour(targetTime);
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const source = child.frigate?.recording ?? child.frigate?.event;
|
||||
if (source && source.end_time && child.frigate?.cameraID) {
|
||||
const start = source.start_time * 1000;
|
||||
const end = source.end_time * 1000;
|
||||
let seekSeconds: number | null = null;
|
||||
for (const [index, child] of media.entries()) {
|
||||
if (!ViewMediaClassifier.isMediaWithStartEndTime(child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetTime.getTime() >= start && targetTime.getTime() <= end) {
|
||||
const segments = segmentsDataset.get({
|
||||
filter: (segment) =>
|
||||
segment.cameraID === child.frigate?.cameraID &&
|
||||
segment.start >= start &&
|
||||
segment.end <= end,
|
||||
order: sortOldestToYoungest,
|
||||
});
|
||||
const start = child.getStartTime();
|
||||
const end = child.getEndTime();
|
||||
let seekSeconds: number | null = null;
|
||||
|
||||
if (targetTime >= start && targetTime <= end) {
|
||||
const query = dataManager.generateDefaultRecordingSegmentsQueries(
|
||||
child.getCameraID(),
|
||||
{
|
||||
start: start,
|
||||
end: end,
|
||||
},
|
||||
)[0];
|
||||
const segments = (await dataManager.getRecordingSegments(hass, query)).get(query);
|
||||
|
||||
if (segments) {
|
||||
seekSeconds = getSeekTimeInSegments(
|
||||
// Recordings start from the top of the hour.
|
||||
child.frigate.recording ? hourStart : fromUnixTime(source.start_time),
|
||||
child.isRecording() ? hourStart : start,
|
||||
targetTime,
|
||||
segments,
|
||||
segments.segments,
|
||||
);
|
||||
}
|
||||
|
||||
if (seekSeconds !== null) {
|
||||
seek.set(index, {
|
||||
seekSeconds: seekSeconds,
|
||||
seekTime: targetTime.getTime() / 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (seekSeconds !== null) {
|
||||
seek.set(index, {
|
||||
seekSeconds: seekSeconds,
|
||||
seekTime: targetTime.getTime() / 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the relevant recording child given a date target.
|
||||
* @param children The FrigateBrowseMediaSource[] children. Must be sorted
|
||||
* most recent first.
|
||||
* Find the closest matching media object.
|
||||
* @param mediaArray The media. Must be sorted most recent first.
|
||||
* @param targetTime The target time used to find the relevant child.
|
||||
* @param cameraIDs The camera IDs to search for.
|
||||
* @param refPoint Whether to find based on the start or end of the
|
||||
@@ -244,8 +227,8 @@ export const generateMediaViewerContextForChildren = (
|
||||
* the best match.
|
||||
* @returns The childindex or null if no matching child is found.
|
||||
*/
|
||||
export const findChildIndex = (
|
||||
children: FrigateBrowseMediaSource[],
|
||||
export const findClosestMediaIndex = (
|
||||
mediaArray: ViewMedia[],
|
||||
targetTime: Date,
|
||||
cameraIDs: Set<string>,
|
||||
refPoint?: 'start' | 'end',
|
||||
@@ -257,27 +240,28 @@ export const findChildIndex = (
|
||||
}
|
||||
| undefined;
|
||||
|
||||
for (let i = 0; i < children.length; ++i) {
|
||||
const child = children[i];
|
||||
if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) {
|
||||
const source = child.frigate.event ?? child.frigate.recording;
|
||||
if (!source?.start_time || !source?.end_time) {
|
||||
continue;
|
||||
}
|
||||
const startTime = fromUnixTime(source.start_time);
|
||||
const endTime = fromUnixTime(source.end_time);
|
||||
for (let i = 0; i < mediaArray.length; ++i) {
|
||||
const media = mediaArray[i];
|
||||
if (
|
||||
!cameraIDs.has(media.getCameraID()) ||
|
||||
!ViewMediaClassifier.isMediaWithStartEndTime(media)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startTime <= targetTime && endTime >= targetTime) {
|
||||
if (!refPoint) {
|
||||
return i;
|
||||
}
|
||||
const delta =
|
||||
refPoint === 'end'
|
||||
? endTime.getTime() - targetTime.getTime()
|
||||
: targetTime.getTime() - startTime.getTime();
|
||||
if (!bestMatch || delta < bestMatch.delta) {
|
||||
bestMatch = { index: i, delta: delta };
|
||||
}
|
||||
const startTime = media.getStartTime();
|
||||
const endTime = media.getEndTime();
|
||||
|
||||
if (startTime <= targetTime && endTime >= targetTime) {
|
||||
if (!refPoint) {
|
||||
return i;
|
||||
}
|
||||
const delta =
|
||||
refPoint === 'end'
|
||||
? endTime.getTime() - targetTime.getTime()
|
||||
: targetTime.getTime() - startTime.getTime();
|
||||
if (!bestMatch || delta < bestMatch.delta) {
|
||||
bestMatch = { index: i, delta: delta };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,7 +279,7 @@ export const findChildIndex = (
|
||||
const getSeekTimeInSegments = (
|
||||
startTime: Date,
|
||||
targetTime: Date,
|
||||
segments: RecordingSegmentsItem[],
|
||||
segments: RecordingSegments,
|
||||
): number | null => {
|
||||
if (!segments.length) {
|
||||
return null;
|
||||
@@ -304,13 +288,14 @@ const getSeekTimeInSegments = (
|
||||
|
||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||
for (const segment of segments) {
|
||||
if (segment.start > targetTime.getTime()) {
|
||||
const segmentStart = fromUnixTime(segment.start_time);
|
||||
if (segmentStart > targetTime) {
|
||||
break;
|
||||
}
|
||||
const start =
|
||||
segment.start < startTime.getTime() ? startTime.getTime() : segment.start;
|
||||
const end = segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end;
|
||||
seekMilliseconds += end - start;
|
||||
const segmentEnd = fromUnixTime(segment.end_time);
|
||||
const start = segmentStart < startTime ? startTime : segmentStart;
|
||||
const end = segmentEnd > targetTime ? targetTime : segmentEnd;
|
||||
seekMilliseconds += end.getTime() - start.getTime();
|
||||
}
|
||||
return seekMilliseconds / 1000;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import sub from 'date-fns/sub';
|
||||
import { DataSet } from 'vis-data';
|
||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||
import { CameraConfig, ClipsOrSnapshotsOrAll } from '../types';
|
||||
import { DataManager } from './data/data-manager';
|
||||
import { EventQuery } from './data/data-types';
|
||||
import { RecordingSegment, RecordingSegments } from './frigate';
|
||||
import { capEndDate, convertRangeToCacheFriendlyTimes } from './data/data-manager-util';
|
||||
import { EventMediaQueries } from '../view';
|
||||
import { ViewMedia } from '../view-media';
|
||||
import { compressRanges, MemoryRangeSet } from './data/data-manager-range';
|
||||
import { ModifyInterface } from './basic';
|
||||
|
||||
// Allow timeline freshness to be at least this number of seconds out of date
|
||||
// (caching times in the data-engine may increase the effective delay).
|
||||
const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30;
|
||||
|
||||
// Number of seconds gap allowable in order to consider two recording segments
|
||||
// to be consecutive. Some low performance cameras have trouble and without a
|
||||
// generous allowance here the timeline may be littered with individual segments
|
||||
// instead of clean recording blocks.
|
||||
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
|
||||
|
||||
export interface FrigateCardTimelineItem extends TimelineItem {
|
||||
// Use numbers to avoid significant volumes of Date object construction (for
|
||||
// high-quantity recording segments).
|
||||
start: number;
|
||||
end?: number;
|
||||
media?: ViewMedia;
|
||||
}
|
||||
|
||||
export class TimelineDataSource {
|
||||
protected _dataManager: DataManager;
|
||||
protected _dataset: DataSet<FrigateCardTimelineItem> = new DataSet();
|
||||
|
||||
// The ranges in which recordings have been calculated and added for.
|
||||
protected _recordingRanges = new MemoryRangeSet();
|
||||
|
||||
protected _cameraIDs: Set<string>;
|
||||
protected _mediaType: ClipsOrSnapshotsOrAll;
|
||||
|
||||
constructor(
|
||||
dataManager: DataManager,
|
||||
cameraIDs: Set<string>,
|
||||
media: ClipsOrSnapshotsOrAll,
|
||||
) {
|
||||
this._dataManager = dataManager;
|
||||
this._cameraIDs = cameraIDs;
|
||||
this._mediaType = media;
|
||||
}
|
||||
|
||||
get dataset(): DataSet<FrigateCardTimelineItem> {
|
||||
return this._dataset;
|
||||
}
|
||||
|
||||
public clearEvents(): void {
|
||||
this._dataset.remove(
|
||||
this._dataset.get({
|
||||
filter: (item) => item.type !== 'background',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public rewriteEvent(id: IdType): void {
|
||||
// Hack: For timeline uses of the event dataset clustering may not update
|
||||
// unless the dataset changes, artifically update the dataset to ensure the
|
||||
// newly selected item cannot be included in a cluster.
|
||||
|
||||
// Hack2: Cannot use `updateOnly` here, as vis-data loses the object
|
||||
// prototype, see: https://github.com/visjs/vis-data/issues/997 . Instead,
|
||||
// remove then add.
|
||||
const item = this._dataset.get(id);
|
||||
if (item) {
|
||||
this._dataset.remove(id);
|
||||
this._dataset.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
public async refresh(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
window: TimelineWindow,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
this._refreshEvents(hass, cameras, window),
|
||||
this._refreshRecordings(hass, window),
|
||||
]);
|
||||
}
|
||||
|
||||
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] {
|
||||
const _window = convertRangeToCacheFriendlyTimes(window, {
|
||||
endCap: true,
|
||||
});
|
||||
return this._dataManager.generateDefaultEventQueries(this._cameraIDs, {
|
||||
start: _window.start,
|
||||
end: _window.end,
|
||||
...(this._mediaType === 'clips' && { hasClip: true }),
|
||||
...(this._mediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
}
|
||||
|
||||
protected async _refreshEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
window: TimelineWindow,
|
||||
): Promise<void> {
|
||||
const query = new EventMediaQueries(this.getTimelineEventQueries(window));
|
||||
const results = await this._dataManager.executeMediaQuery(hass, query);
|
||||
for (const media of results?.getResults() ?? []) {
|
||||
const endTime = media.getEndTime();
|
||||
const startTime = media.getStartTime();
|
||||
const id = media.getID(cameras.get(media.getCameraID()));
|
||||
if (id && startTime) {
|
||||
this._dataset.update({
|
||||
id: id,
|
||||
group: media.getCameraID(),
|
||||
content: '',
|
||||
media: media,
|
||||
start: startTime.getTime(),
|
||||
type: endTime ? 'range' : 'point',
|
||||
...(endTime && { end: endTime.getTime() }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async _refreshRecordings(
|
||||
hass: HomeAssistant,
|
||||
window: TimelineWindow,
|
||||
): Promise<void> {
|
||||
type FrigateCardTimelineItemWithEnd = ModifyInterface<
|
||||
FrigateCardTimelineItem,
|
||||
{ end: number }
|
||||
>;
|
||||
|
||||
const convertSegmentToRecording = (
|
||||
cameraID: string,
|
||||
segment: RecordingSegment,
|
||||
): FrigateCardTimelineItemWithEnd => {
|
||||
return {
|
||||
id: `recording-${cameraID}-${segment.id}`,
|
||||
group: cameraID,
|
||||
start: segment.start_time * 1000,
|
||||
end: segment.end_time * 1000,
|
||||
content: '',
|
||||
type: 'background',
|
||||
};
|
||||
};
|
||||
|
||||
const getExistingRecordingsForCameraID = (
|
||||
cameraID: string,
|
||||
): FrigateCardTimelineItemWithEnd[] => {
|
||||
return this._dataset.get({
|
||||
filter: (item) =>
|
||||
item.type == 'background' && item.group === cameraID && item.end !== undefined,
|
||||
}) as FrigateCardTimelineItemWithEnd[];
|
||||
};
|
||||
|
||||
const deleteRecordingsForCameraID = (cameraID: string): void => {
|
||||
this._dataset.remove(
|
||||
this._dataset.get({
|
||||
filter: (item) => item.type === 'background' && item.group === cameraID,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const addRecordings = (recordings: FrigateCardTimelineItemWithEnd[]): void => {
|
||||
this._dataset.add(recordings);
|
||||
};
|
||||
|
||||
// Calculate an end date that's slightly short of the current time to allow
|
||||
// for caching up to the freshness tolerance.
|
||||
const end = sub(capEndDate(window.end), {
|
||||
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
||||
});
|
||||
const hasCoverage = this._recordingRanges.hasCoverage({
|
||||
start: window.start,
|
||||
end: end,
|
||||
});
|
||||
if (hasCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, {
|
||||
endCap: true,
|
||||
});
|
||||
|
||||
const queries = this._dataManager.generateDefaultRecordingSegmentsQueries(
|
||||
this._cameraIDs,
|
||||
{
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: cacheFriendlyWindow.end,
|
||||
},
|
||||
);
|
||||
|
||||
const results = await this._dataManager.getRecordingSegments(hass, queries);
|
||||
|
||||
const newSegments: Map<string, RecordingSegments> = new Map();
|
||||
for (const [query, result] of results) {
|
||||
let destination: RecordingSegments | undefined = newSegments.get(query.cameraID);
|
||||
if (!destination) {
|
||||
destination = [];
|
||||
newSegments.set(query.cameraID, destination);
|
||||
}
|
||||
result.segments.forEach((segment) => destination?.push(segment));
|
||||
}
|
||||
|
||||
for (const [cameraID, segments] of newSegments.entries()) {
|
||||
const existingRecordings = getExistingRecordingsForCameraID(cameraID);
|
||||
const mergedRecordings = existingRecordings.concat(
|
||||
segments.map((segment) => convertSegmentToRecording(cameraID, segment)),
|
||||
);
|
||||
const compressedRecordings = compressRanges(
|
||||
mergedRecordings,
|
||||
TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS,
|
||||
) as FrigateCardTimelineItemWithEnd[];
|
||||
|
||||
deleteRecordingsForCameraID(cameraID);
|
||||
addRecordings(compressedRecordings);
|
||||
}
|
||||
|
||||
this._recordingRanges.add({ start: window.start, end: end });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user