Rename DataManager to CameraManager.

This commit is contained in:
Dermot Duffy
2023-01-24 19:36:54 -08:00
parent e0d2dfaa60
commit 39b44863ce
23 changed files with 183 additions and 187 deletions
-131
View File
@@ -1,131 +0,0 @@
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 = new Date();
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 _contains(a: Request, b: Request): boolean {
return isEqual(a, b);
}
protected _expireOldRequests(): void {
const now = new Date();
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;
}
}
@@ -1,42 +0,0 @@
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;
}
}
@@ -1,397 +0,0 @@
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;
}
}
-77
View File
@@ -1,77 +0,0 @@
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;
}
-3
View File
@@ -1,3 +0,0 @@
import { FrigateCardError } from '../../types';
export class DataManagerError extends FrigateCardError {}
-125
View File
@@ -1,125 +0,0 @@
import orderBy from 'lodash-es/orderBy';
interface Range<T extends Date | number> {
start: T;
end: T;
}
export type DateRange = Range<Date>;
interface MemoryRangeSetInterface<T> {
hasCoverage(range: T): boolean;
add(range: T): void;
clear(): void;
}
export class MemoryRangeSet implements MemoryRangeSetInterface<DateRange> {
protected _ranges: DateRange[];
constructor(ranges?: DateRange[]) {
this._ranges = ranges ?? [];
}
public hasCoverage(range: DateRange): boolean {
return this._ranges.some((cachedRange) =>
rangeIsEntirelyContained(cachedRange, range),
);
}
public add(range: DateRange): void {
this._ranges.push(range);
this._ranges = compressRanges(this._ranges);
}
public clear(): void {
this._ranges = [];
}
}
interface ExpiringRange<T extends Date | number> extends Range<T> {
expires: Date;
}
export class ExpiringMemoryRangeSet
implements MemoryRangeSetInterface<ExpiringRange<Date>>
{
protected _ranges: ExpiringRange<Date>[];
constructor(ranges?: ExpiringRange<Date>[]) {
this._ranges = ranges ?? [];
}
public hasCoverage(range: DateRange): boolean {
const now = new Date();
return this._ranges.some(
(cachedRange) =>
now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range),
);
}
public add(range: ExpiringRange<Date>): void {
this._expireOldRanges();
this._ranges.push(range);
}
protected _expireOldRanges(): void {
const now = new Date();
this._ranges = this._ranges.filter((range) => now < range.expires);
}
public clear(): void {
this._ranges = [];
}
}
const rangeIsEntirelyContained = (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;
};
-44
View File
@@ -1,44 +0,0 @@
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;
};
-310
View File
@@ -1,310 +0,0 @@
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 new MediaQueriesResults(
orderBy(mediaArray, (media) => media.getStartTime(), 'desc'),
// Select the first (most-recent) item.
mediaArray.length ? 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;
}
}
-138
View File
@@ -1,138 +0,0 @@
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;
}
+18 -18
View File
@@ -6,7 +6,7 @@ import { ViewContext } from 'view';
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
import { EventMediaQueries, RecordingMediaQueries, View } from '../view';
import { RecordingSegments } from './frigate';
import { DataManager } from './data/data-manager';
import { CameraManager } from '../camera/manager';
import { getAllDependentCameras } from './camera.js';
import { ViewMedia, ViewMediaClassifier } from '../view-media';
import { HomeAssistant } from 'custom-card-helpers';
@@ -14,7 +14,7 @@ import { HomeAssistant } from 'custom-card-helpers';
export const changeViewToRecentEventsForCameraAndDependents = async (
element: HTMLElement,
hass: HomeAssistant,
dataManager: DataManager,
cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View,
options?: {
@@ -23,7 +23,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
},
): Promise<void> => {
(
await createViewForEvents(hass, dataManager, cameras, view, {
await createViewForEvents(hass, cameraManager, cameras, view, {
...options,
limit: 50, // Capture the 50 most recent events.
})
@@ -32,7 +32,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
export const createViewForEvents = async (
hass: HomeAssistant,
dataManager: DataManager,
cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View,
options?: {
@@ -51,7 +51,7 @@ export const createViewForEvents = async (
? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera));
const queries = dataManager.generateDefaultEventQueries(cameraIDs, {
const queries = cameraManager.generateDefaultEventQueries(cameraIDs, {
...(options?.limit && { limit: options.limit }),
...((!options?.mediaType || ['clips', 'all'].includes(options.mediaType)) && {
hasClip: true,
@@ -60,7 +60,7 @@ export const createViewForEvents = async (
});
query = new EventMediaQueries(queries);
}
const queryResults = await dataManager.executeMediaQuery(hass, query);
const queryResults = await cameraManager.executeMediaQuery(hass, query);
return view?.evolve({
view: options?.targetView,
@@ -73,7 +73,7 @@ export const createViewForEvents = async (
* Change the view to a recent recording.
* @param element The element to dispatch the view change from.
* @param hass The Home Assistant object.
* @param dataManager The datamanager to use for data access.
* @param cameraManager The datamanager to use for data access.
* @param cameras The camera configurations.
* @param view The current view.
* @param options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to.
@@ -81,7 +81,7 @@ export const createViewForEvents = async (
export const changeViewToRecentRecordingForCameraAndDependents = async (
element: HTMLElement,
hass: HomeAssistant,
dataManager: DataManager,
cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View,
options?: {
@@ -90,7 +90,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
): Promise<void> => {
const now = new Date();
(
await createViewForRecordings(hass, dataManager, cameras, view, {
await createViewForRecordings(hass, cameraManager, cameras, view, {
...options,
// Fetch 7 days worth of recordings (including recordings that are for the
// current hour).
@@ -103,7 +103,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
/**
* Create a view for recordings.
* @param hass The Home Assistant object.
* @param dataManager The datamanager to use for data access.
* @param cameraManager The datamanager to use for data access.
* @param cameras The camera configurations.
* @param view The current view.
* @param options A specific window (start and end) to fetch recordings for, a
@@ -112,7 +112,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
*/
export const createViewForRecordings = async (
hass: HomeAssistant,
dataManager: DataManager,
cameraManager: CameraManager,
cameras: Map<string, CameraConfig>,
view: View,
options?: {
@@ -127,13 +127,13 @@ export const createViewForRecordings = async (
? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera));
const queries = dataManager.generateDefaultRecordingQueries(cameraIDs, {
const queries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
...(options?.start && { start: options.start }),
...(options?.end && { end: options.end }),
});
const query = new RecordingMediaQueries(queries);
const queryResults = await dataManager.executeMediaQuery(hass, query);
const queryResults = await cameraManager.executeMediaQuery(hass, query);
let viewerContext: ViewContext | undefined = {};
const mediaArray = queryResults?.getResults();
@@ -143,7 +143,7 @@ export const createViewForRecordings = async (
);
viewerContext = await generateMediaViewerContext(
hass,
dataManager,
cameraManager,
mediaArray,
options.targetTime,
);
@@ -164,14 +164,14 @@ export const createViewForRecordings = async (
* 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 cameraManager The datamanager to use for data access.
* @param media The media.
* @param targetTime The target time.
* @returns The ViewContext.
*/
export const generateMediaViewerContext = async (
hass: HomeAssistant,
dataManager: DataManager,
cameraManager: CameraManager,
media: ViewMedia[],
targetTime: Date,
): Promise<ViewContext> => {
@@ -188,14 +188,14 @@ export const generateMediaViewerContext = async (
let seekSeconds: number | null = null;
if (targetTime >= start && targetTime <= end) {
const query = dataManager.generateDefaultRecordingSegmentsQueries(
const query = cameraManager.generateDefaultRecordingSegmentsQueries(
child.getCameraID(),
{
start: start,
end: end,
},
)[0];
const segments = (await dataManager.getRecordingSegments(hass, query)).get(query);
const segments = (await cameraManager.getRecordingSegments(hass, query)).get(query);
if (segments) {
seekSeconds = getSeekTimeInSegments(
+11 -15
View File
@@ -4,17 +4,13 @@ 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 { CameraManager } from '../camera/manager';
import { EventQuery } from '../camera/types';
import { RecordingSegment, RecordingSegments } from './frigate';
import { capEndDate, convertRangeToCacheFriendlyTimes } from './data/data-manager-util';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util';
import { EventMediaQueries } from '../view';
import { ViewMedia } from '../view-media';
import {
compressRanges,
ExpiringMemoryRangeSet,
MemoryRangeSet,
} from './data/data-manager-range';
import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range';
import { ModifyInterface } from './basic';
// Allow timeline freshness to be at least this number of seconds out of date
@@ -36,7 +32,7 @@ export interface FrigateCardTimelineItem extends TimelineItem {
}
export class TimelineDataSource {
protected _dataManager: DataManager;
protected _cameraManager: CameraManager;
protected _dataset: DataSet<FrigateCardTimelineItem> = new DataSet();
// The ranges in which recordings have been calculated and added for.
@@ -53,11 +49,11 @@ export class TimelineDataSource {
protected _mediaType: ClipsOrSnapshotsOrAll;
constructor(
dataManager: DataManager,
cameraManager: CameraManager,
cameraIDs: Set<string>,
media: ClipsOrSnapshotsOrAll,
) {
this._dataManager = dataManager;
this._cameraManager = cameraManager;
this._cameraIDs = cameraIDs;
this._mediaType = media;
}
@@ -108,7 +104,7 @@ export class TimelineDataSource {
}
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] {
return this._dataManager.generateDefaultEventQueries(this._cameraIDs, {
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
start: window.start,
end: window.end,
...(this._mediaType === 'clips' && { hasClip: true }),
@@ -137,7 +133,7 @@ export class TimelineDataSource {
this.getTimelineEventQueries(cacheFriendlyWindow),
);
const results = await this._dataManager.executeMediaQuery(hass, query);
const results = await this._cameraManager.executeMediaQuery(hass, query);
for (const media of results?.getResults() ?? []) {
const endTime = media.getEndTime();
const startTime = media.getStartTime();
@@ -222,7 +218,7 @@ export class TimelineDataSource {
endCap: true,
});
const queries = this._dataManager.generateDefaultRecordingSegmentsQueries(
const queries = this._cameraManager.generateDefaultRecordingSegmentsQueries(
this._cameraIDs,
{
start: cacheFriendlyWindow.start,
@@ -230,7 +226,7 @@ export class TimelineDataSource {
},
);
const results = await this._dataManager.getRecordingSegments(hass, queries);
const results = await this._cameraManager.getRecordingSegments(hass, queries);
const newSegments: Map<string, RecordingSegments> = new Map();
for (const [query, result] of results) {