Convert to batch queries.

This commit is contained in:
Dermot Duffy
2023-01-24 19:36:54 -08:00
parent 92b75c4b1a
commit 04d3ca3f46
10 changed files with 600 additions and 345 deletions
+44 -23
View File
@@ -1,35 +1,32 @@
import { CameraConfig } from '../types'; import { CameraConfig } from '../types';
import { RecordingSegmentsCache } from './cache'; import { RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine'; import { CameraManagerEngine } from './engine';
import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
import { DataQuery } from './types'; import { Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
export class CameraManagerEngineFactory { export class CameraManagerEngineFactory {
protected _engines: Map<string, CameraManagerEngine> = new Map(); protected _engines: Map<Engine, CameraManagerEngine> = new Map();
protected _getOrCreateEngine(engineKey: string): CameraManagerEngine | null { public getEngine(engine: Engine): CameraManagerEngine | null {
const cachedEngine = this._engines.get(engineKey); const cachedEngine = this._engines.get(engine);
if (cachedEngine) { if (cachedEngine) {
return cachedEngine; return cachedEngine;
} }
let newEngine: CameraManagerEngine | null = null; let cameraManagerEngine: CameraManagerEngine | null = null;
switch (engineKey) { switch (engine) {
case 'frigate': case Engine.Frigate:
newEngine = new FrigateCameraManagerEngine(new RecordingSegmentsCache()); cameraManagerEngine = new FrigateCameraManagerEngine(
new RecordingSegmentsCache(),
new RequestCache(),
);
break; break;
} }
if (newEngine) { if (cameraManagerEngine) {
this._engines.set(engineKey, newEngine); this._engines.set(engine, cameraManagerEngine);
} }
return newEngine; return cameraManagerEngine;
}
public getEngineForQuery(
cameras: Map<string, CameraConfig>,
query: DataQuery,
): CameraManagerEngine | null {
const cameraConfig = cameras.get(query.cameraID);
return cameraConfig ? this.getEngineForCamera(cameraConfig) : null;
} }
public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null {
@@ -37,10 +34,34 @@ export class CameraManagerEngineFactory {
return null; return null;
} }
let engineKey: string | null = null; let engine: Engine | null = null;
if (cameraConfig.frigate.camera_name) { if (cameraConfig.frigate.camera_name) {
engineKey = 'frigate'; engine = Engine.Frigate;
} }
return engineKey ? this._getOrCreateEngine(engineKey) : null; return engine ? this.getEngine(engine) : null;
}
public getEnginesForCameraIDs(
cameras: Map<string, CameraConfig>,
cameraIDs: Set<string>,
): CameraManagerEngineCameraIDMap | null {
const output: CameraManagerEngineCameraIDMap = new Map();
for (const cameraID of cameraIDs) {
const cameraConfig = cameras.get(cameraID);
if (!cameraConfig) {
continue;
}
const engine = this.getEngineForCamera(cameraConfig);
if (!engine) {
continue;
}
if (!output.has(engine)) {
output.set(engine, new Set());
}
output.get(engine)?.add(cameraID);
}
return output;
} }
} }
+21 -20
View File
@@ -1,65 +1,67 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types'; import { CameraConfig } from '../types';
import { MediaQueriesResults } from "../view/media-queries-results";
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { import {
DataQuery,
EventQuery, EventQuery,
EventQueryResultsMap,
PartialEventQuery, PartialEventQuery,
PartialRecordingQuery, PartialRecordingQuery,
PartialRecordingSegmentsQuery, PartialRecordingSegmentsQuery,
QueryReturnType, QueryReturnType,
RecordingQuery, RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
} from './types'; } from './types';
import { MediaQueries } from '../view/media-queries';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
export interface CameraManagerEngine { export interface CameraManagerEngine {
generateDefaultEventQuery( generateDefaultEventQuery(
cameraID: string, cameras: Map<string, CameraConfig>,
cameraConfig: CameraConfig, cameraIDs: Set<string>,
query: PartialEventQuery, query: PartialEventQuery,
): EventQuery | null; ): EventQuery[] | null;
generateDefaultRecordingQuery( generateDefaultRecordingQuery(
cameraID: string, cameras: Map<string, CameraConfig>,
cameraConfig: CameraConfig, cameraIDs: Set<string>,
query: PartialRecordingQuery, query: PartialRecordingQuery,
): RecordingQuery | null; ): RecordingQuery[] | null;
generateDefaultRecordingSegmentsQuery( generateDefaultRecordingSegmentsQuery(
cameraID: string, cameras: Map<string, CameraConfig>,
cameraConfig: CameraConfig, cameraIDs: Set<string>,
query: PartialRecordingSegmentsQuery, query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery | null; ): RecordingSegmentsQuery[] | null;
getEvents( getEvents(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
query: EventQuery, query: EventQuery,
): Promise<QueryReturnType<EventQuery> | null>; ): Promise<EventQueryResultsMap | null>;
getRecordings( getRecordings(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
query: RecordingQuery, query: RecordingQuery,
): Promise<QueryReturnType<RecordingQuery> | null>; ): Promise<RecordingQueryResultsMap | null>;
getRecordingSegments( getRecordingSegments(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
query: RecordingSegmentsQuery, query: RecordingSegmentsQuery,
): Promise<QueryReturnType<RecordingSegmentsQuery> | null>; ): Promise<RecordingSegmentsQueryResultsMap | null>;
generateMediaFromEvents( generateMediaFromEvents(
cameraConfig: CameraConfig, cameras: Map<string, CameraConfig>,
query: EventQuery, query: EventQuery,
results: QueryReturnType<EventQuery>, results: QueryReturnType<EventQuery>,
): ViewMedia[] | null; ): ViewMedia[] | null;
generateMediaFromRecordings( generateMediaFromRecordings(
cameraConfig: CameraConfig, cameras: Map<string, CameraConfig>,
query: RecordingQuery, query: RecordingQuery,
results: QueryReturnType<RecordingQuery>, results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null; ): ViewMedia[] | null;
@@ -73,10 +75,9 @@ export interface CameraManagerEngine {
favorite: boolean, favorite: boolean,
): Promise<void>; ): Promise<void>;
areMediaQueriesResultsFresh( getQueryResultMaxAge(
queries: MediaQueries, query: DataQuery
results: MediaQueriesResults, ): number | null;
): boolean;
getMediaSeekTime( getMediaSeekTime(
hass: HomeAssistant, hass: HomeAssistant,
+289 -107
View File
@@ -4,18 +4,19 @@ import endOfHour from 'date-fns/endOfHour';
import startOfHour from 'date-fns/startOfHour'; import startOfHour from 'date-fns/startOfHour';
import { CAMERA_BIRDSEYE } from '../../const'; import { CAMERA_BIRDSEYE } from '../../const';
import { CameraConfig, RecordingSegment } from '../../types'; import { CameraConfig, RecordingSegment } from '../../types';
import { MediaQueriesResults } from '../../view/media-queries-results';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import { RecordingSegmentsCache } from '../cache'; import { RequestCache, RecordingSegmentsCache } from '../cache';
import { import {
CameraManagerEngine, CameraManagerEngine,
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
} from '../engine'; } from '../engine';
import { DateRange } from '../range'; import { DateRange } from '../range';
import { import {
DataQuery,
Engine, Engine,
EventQuery, EventQuery,
EventQueryResults,
EventQueryResultsMap,
FrigateEventQueryResults, FrigateEventQueryResults,
FrigateRecordingQueryResults, FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults, FrigateRecordingSegmentsQueryResults,
@@ -27,7 +28,10 @@ import {
QueryReturnType, QueryReturnType,
QueryType, QueryType,
RecordingQuery, RecordingQuery,
RecordingQueryResults,
RecordingQueryResultsMap,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
} from '../types'; } from '../types';
import { FrigateRecording } from './types'; import { FrigateRecording } from './types';
import { import {
@@ -38,7 +42,6 @@ import {
NativeFrigateRecordingSegmentsQuery, NativeFrigateRecordingSegmentsQuery,
retainEvent, retainEvent,
} from './requests'; } from './requests';
import { MediaQueries } from '../../view/media-queries';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle'; import throttle from 'lodash-es/throttle';
import { runWhenIdleIfSupported } from '../../utils/basic'; import { runWhenIdleIfSupported } from '../../utils/basic';
@@ -78,6 +81,7 @@ class FrigateQueryResultsClassifier {
export class FrigateCameraManagerEngine implements CameraManagerEngine { export class FrigateCameraManagerEngine implements CameraManagerEngine {
protected _recordingSegmentsCache: RecordingSegmentsCache; protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: RequestCache;
// Garbage collect segments at most once an hour. // Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle( protected _throttledSegmentGarbageCollector = throttle(
@@ -86,8 +90,12 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
{ leading: false, trailing: true }, { leading: false, trailing: true },
); );
constructor(recordingSegmentsCache: RecordingSegmentsCache) { constructor(
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache,
) {
this._recordingSegmentsCache = recordingSegmentsCache; this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
} }
public getMediaDownloadPath( public getMediaDownloadPath(
@@ -113,46 +121,81 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
public generateDefaultEventQuery( public generateDefaultEventQuery(
cameraID: string, cameras: Map<string, CameraConfig>,
cameraConfig: CameraConfig, cameraIDs: Set<string>,
query: PartialEventQuery, query: PartialEventQuery,
): EventQuery | null { ): EventQuery[] | null {
return { const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) =>
cameras.get(cameraID),
);
// If there isn't a label or zone specified, we can come up with a single
// batch query for Frigate that will match across all cameras.
const canDoBatchQuery = relevantCameraConfigs.every(
(cameraConfig) => !cameraConfig?.frigate.label && !cameraConfig?.frigate.zone,
);
if (canDoBatchQuery) {
return [
{
type: QueryType.Event, type: QueryType.Event,
cameraID: cameraID, cameraIDs: cameraIDs,
...(cameraConfig.frigate.label && { what: cameraConfig.frigate.label }),
...(cameraConfig.frigate.zone && { where: cameraConfig.frigate.zone }),
...query, ...query,
}; },
];
}
const output: EventQuery[] = [];
for (const cameraID of cameraIDs) {
const cameraConfig = cameras.get(cameraID);
if (cameraConfig) {
output.push({
type: QueryType.Event,
cameraIDs: new Set([cameraID]),
...(cameraConfig.frigate.label && {
what: new Set([cameraConfig.frigate.label]),
}),
...(cameraConfig.frigate.zone && {
where: new Set([cameraConfig.frigate.zone]),
}),
...query,
});
}
}
return output.length ? output : null;
} }
public generateDefaultRecordingQuery( public generateDefaultRecordingQuery(
cameraID: string, _cameras: Map<string, CameraConfig>,
_cameraConfig: CameraConfig, cameraIDs: Set<string>,
query: PartialRecordingQuery, query: PartialRecordingQuery,
): RecordingQuery | null { ): RecordingQuery[] | null {
return { return [
{
type: QueryType.Recording, type: QueryType.Recording,
cameraID: cameraID, cameraIDs: cameraIDs,
...query, ...query,
}; },
];
} }
public generateDefaultRecordingSegmentsQuery( public generateDefaultRecordingSegmentsQuery(
cameraID: string, _cameras: Map<string, CameraConfig>,
_cameraConfig: CameraConfig, cameraIDs: Set<string>,
query: PartialRecordingSegmentsQuery, query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery | null { ): RecordingSegmentsQuery[] | null {
if (!query.start || !query.end) { if (!query.start || !query.end) {
return null; return null;
} }
return { return [
{
type: QueryType.RecordingSegments, type: QueryType.RecordingSegments,
cameraID: cameraID, cameraIDs: cameraIDs,
start: query.start, start: query.start,
end: query.end, end: query.end,
...query, ...query,
}; },
];
} }
public async favoriteMedia( public async favoriteMedia(
@@ -169,49 +212,118 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
media.setFavorite(favorite); media.setFavorite(favorite);
} }
protected _buildInstanceToCameraIDMapFromQuery(
cameras: Map<string, CameraConfig>,
query: DataQuery,
): Map<string, Set<string>> {
const output: Map<string, Set<string>> = new Map();
for (const cameraID of query.cameraIDs) {
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
const clientID = cameraConfig?.frigate.client_id;
if (clientID) {
if (!output.has(clientID)) {
output.set(clientID, new Set());
}
output.get(clientID)?.add(cameraID);
}
}
return output;
}
protected _getFrigateCameraNamesForCameraIDs(
cameras: Map<string, CameraConfig>,
cameraIDs: Set<string>,
): Set<string> {
const output = new Set<string>();
for (const cameraID of cameraIDs) {
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
if (cameraConfig?.frigate.camera_name) {
output.add(cameraConfig.frigate.camera_name);
}
}
return output;
}
public async getEvents( public async getEvents(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
query: EventQuery, query: EventQuery,
): Promise<QueryReturnType<EventQuery> | null> { ): Promise<EventQueryResultsMap | null> {
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID); const output: EventQueryResultsMap = new Map();
if (!cameraConfig) {
return null; const processInstanceQuery = async (
instanceID: string,
cameraIDs?: Set<string>,
): Promise<void> => {
if (!cameraIDs || !cameraIDs.size) {
return;
}
const instanceQuery = { ...query, cameraIDs: cameraIDs };
const cachedResult = this._requestCache.get(instanceQuery);
if (cachedResult) {
output.set(query, cachedResult as EventQueryResults);
return;
} }
const nativeQuery: NativeFrigateEventQuery = { const nativeQuery: NativeFrigateEventQuery = {
instance_id: cameraConfig.frigate.client_id, instance_id: instanceID,
camera: cameraConfig.frigate.camera_name, cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)),
...(query.what && { label: query.what }), ...(query.what && { label: Array.from(query.what) }),
...(query.where && { zone: query.where }), ...(query.where && { zone: Array.from(query.where) }),
...(query?.end && { before: Math.floor(query.end.getTime() / 1000) }), ...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
...(query?.start && { after: Math.floor(query.start.getTime() / 1000) }), ...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
...(query?.limit && { limit: query.limit }), ...(query.limit && { limit: query.limit }),
...(query?.hasClip && { has_clip: query.hasClip }), ...(query.hasClip && { has_clip: query.hasClip }),
...(query?.hasSnapshot && { has_snapshot: query.hasSnapshot }), ...(query.hasSnapshot && { has_snapshot: query.hasSnapshot }),
limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
}; };
return <FrigateEventQueryResults>{ const result: FrigateEventQueryResults = {
type: QueryResultsType.Event, type: QueryResultsType.Event,
engine: Engine.Frigate, engine: Engine.Frigate,
instanceID: instanceID,
events: await getEvents(hass, nativeQuery), events: await getEvents(hass, nativeQuery),
expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }),
cached: false, cached: false,
}; };
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
output.set(instanceQuery, result);
};
// Frigate allows multiple cameras to be searched for events in a single
// query. Break them down into groups of cameras per Frigate instance, then
// query once per instance for all cameras in that instance.
const instances = this._buildInstanceToCameraIDMapFromQuery(cameras, query);
await Promise.all(
Array.from(instances.keys()).map((instanceID) =>
processInstanceQuery(instanceID, instances.get(instanceID)),
),
);
return output.size ? output : null;
} }
public async getRecordings( public async getRecordings(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
query: RecordingQuery, query: RecordingQuery,
): Promise<QueryReturnType<RecordingQuery> | null> { ): Promise<RecordingQueryResultsMap | null> {
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID); const output: RecordingQueryResultsMap = new Map();
if (!cameraConfig) {
return null; const processQuery = async (query: RecordingQuery): Promise<void> => {
const cachedResult = this._requestCache.get(query);
if (cachedResult) {
output.set(query, cachedResult as RecordingQueryResults);
return;
} }
// There will only ever be a single cameraID specified for queries in this
// inner function.
const cameraID = [...query.cameraIDs][0];
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
if (!cameraConfig || !cameraConfig.frigate.camera_name) { if (!cameraConfig || !cameraConfig.frigate.camera_name) {
return null; return;
} }
const recordingSummary = await getRecordingsSummary( const recordingSummary = await getRecordingsSummary(
@@ -232,7 +344,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
(!query.end || endHour <= query.end) (!query.end || endHour <= query.end)
) { ) {
recordings.push({ recordings.push({
cameraID: query.cameraID, cameraID: cameraID,
startTime: startHour, startTime: startHour,
endTime: endHour, endTime: endHour,
events: hourData.events, events: hourData.events,
@@ -251,44 +363,65 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
).slice(0, query.limit); ).slice(0, query.limit);
} }
return <FrigateRecordingQueryResults>{ const result: FrigateRecordingQueryResults = {
type: QueryResultsType.Recording, type: QueryResultsType.Recording,
engine: Engine.Frigate, engine: Engine.Frigate,
instanceID: cameraConfig.frigate.client_id,
recordings: recordings, recordings: recordings,
expiry: add(new Date(), { expiry: add(new Date(), {
seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS,
}), }),
cached: false, cached: false,
}; };
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
output.set(query, result);
};
// Frigate recordings can only be queried for a single camera, so fan out
// the inbound query into multiple outbound queries.
await Promise.all(
Array.from(query.cameraIDs).map((cameraID) =>
processQuery({ ...query, cameraIDs: new Set([cameraID]) }),
),
);
return output.size ? output : null;
} }
public async getRecordingSegments( public async getRecordingSegments(
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
query: RecordingSegmentsQuery, query: RecordingSegmentsQuery,
): Promise<QueryReturnType<RecordingSegmentsQuery> | null> { ): Promise<RecordingSegmentsQueryResultsMap | null> {
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID); const output: RecordingSegmentsQueryResultsMap = new Map();
const processQuery = async (query: RecordingSegmentsQuery): Promise<void> => {
// There will only ever be a single cameraID specified for queries in this
// inner function.
const cameraID = [...query.cameraIDs][0];
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
if (!cameraConfig || !cameraConfig.frigate.camera_name) { if (!cameraConfig || !cameraConfig.frigate.camera_name) {
return null; return;
} }
const range: DateRange = { start: query.start, end: query.end }; const range: DateRange = { start: query.start, end: query.end };
// A note on Frigate Recording Segments: // A note on Frigate Recording Segments:
// - Unlike other query types, there is an internal cache at the engine // - There is an internal cache at the engine level for segments to allow
// level for segments to allow caching "within an existing query" (e.g. if // caching "within an existing query" (e.g. if we already cached hour
// we already cached hour 1-8, we will avoid a fetch if we request hours // 1-8, we will avoid a fetch if we request hours 2-3 even though the
// 2-3 even though the query is different -- the segments won't be). This // query is different -- the segments won't be). This is since the
// is since the volume of data in segment transfers can be high, and the // volume of data in segment transfers can be high, and the segments can
// segments can be used in high frequency situations (e.g. video seeking). // be used in high frequency situations (e.g. video seeking).
const cachedSegments = this._recordingSegmentsCache.get(query.cameraID, range); const cachedSegments = this._recordingSegmentsCache.get(cameraID, range);
if (cachedSegments) { if (cachedSegments) {
return { output.set(query, <FrigateRecordingSegmentsQueryResults>{
type: QueryResultsType.RecordingSegments, type: QueryResultsType.RecordingSegments,
engine: Engine.Frigate, engine: Engine.Frigate,
instanceID: cameraConfig.frigate.client_id,
segments: cachedSegments, segments: cachedSegments,
cached: true, cached: true,
}; });
return;
} }
const request: NativeFrigateRecordingSegmentsQuery = { const request: NativeFrigateRecordingSegmentsQuery = {
@@ -299,20 +432,55 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
}; };
const segments = await getRecordingSegments(hass, request); const segments = await getRecordingSegments(hass, request);
this._recordingSegmentsCache.add(query.cameraID, range, segments); this._recordingSegmentsCache.add(cameraID, range, segments);
runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); output.set(query, <FrigateRecordingSegmentsQueryResults>{
return {
type: QueryResultsType.RecordingSegments, type: QueryResultsType.RecordingSegments,
engine: Engine.Frigate, engine: Engine.Frigate,
instanceID: cameraConfig.frigate.client_id,
segments: segments, segments: segments,
cached: false, cached: false,
});
}; };
// Frigate recording segments can only be queried for a single camera, so
// fan out the inbound query into multiple outbound queries.
await Promise.all(
Array.from(query.cameraIDs).map((cameraID) =>
processQuery({ ...query, cameraIDs: new Set([cameraID]) }),
),
);
runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras));
return output.size ? output : null;
}
protected _getCameraIDMatch(
cameras: Map<string, CameraConfig>,
query: DataQuery,
instanceID: string,
cameraName: string,
): string | null {
// If the query is only for a single cameraID, all results are assumed to
// belong to it for performance reasons. Otherwise, we need to map the
// instanceID and camera name for the known cameras, and get the precise
// cameraID that matches the expected instance ID / camera name.
if (query.cameraIDs.size === 1) {
return [...query.cameraIDs][0];
}
for (const [cameraID, cameraConfig] of cameras.entries()) {
if (
cameraConfig.frigate.client_id === instanceID &&
cameraConfig.frigate.camera_name === cameraName
) {
return cameraID;
}
}
return null;
} }
public generateMediaFromEvents( public generateMediaFromEvents(
cameraConfig: CameraConfig, cameras: Map<string, CameraConfig>,
query: EventQuery, query: EventQuery,
results: QueryReturnType<EventQuery>, results: QueryReturnType<EventQuery>,
): ViewMedia[] | null { ): ViewMedia[] | null {
@@ -322,6 +490,19 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
const output: ViewMedia[] = []; const output: ViewMedia[] = [];
for (const event of results.events) { for (const event of results.events) {
const cameraID = this._getCameraIDMatch(
cameras,
query,
results.instanceID,
event.camera,
);
if (!cameraID) {
continue;
}
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
if (!cameraConfig) {
continue;
}
let mediaType: 'clip' | 'snapshot' | null = null; let mediaType: 'clip' | 'snapshot' | null = null;
if ( if (
!query.hasClip && !query.hasClip &&
@@ -339,7 +520,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
const media = FrigateViewMediaFactory.createEventViewMedia( const media = FrigateViewMediaFactory.createEventViewMedia(
mediaType, mediaType,
query.cameraID, cameraID,
cameraConfig, cameraConfig,
event, event,
); );
@@ -351,8 +532,8 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
public generateMediaFromRecordings( public generateMediaFromRecordings(
cameraConfig: CameraConfig, cameras: Map<string, CameraConfig>,
query: RecordingQuery, _query: RecordingQuery,
results: QueryReturnType<RecordingQuery>, results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null { ): ViewMedia[] | null {
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) { if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) {
@@ -361,8 +542,12 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
const output: ViewMedia[] = []; const output: ViewMedia[] = [];
for (const recording of results.recordings) { for (const recording of results.recordings) {
const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID);
if (!cameraConfig) {
continue;
}
const media = FrigateViewMediaFactory.createRecordingViewMedia( const media = FrigateViewMediaFactory.createRecordingViewMedia(
query.cameraID, recording.cameraID,
recording, recording,
cameraConfig, cameraConfig,
); );
@@ -373,23 +558,13 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
return output; return output;
} }
public areMediaQueriesResultsFresh( public getQueryResultMaxAge(query: DataQuery): number | null {
queries: MediaQueries, if (query.type === QueryType.Event) {
results: MediaQueriesResults, return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
): boolean { } else if (query.type === QueryType.Recording) {
let freshThreshold: number | null = null; return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
if (MediaQueriesClassifier.areEventQueries(queries)) {
freshThreshold = EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (MediaQueriesClassifier.areRecordingQueries(queries)) {
freshThreshold = RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
} }
const now = new Date(); return null;
const resultsTimestamp = results.getResultsTimestamp();
return (
!freshThreshold ||
!resultsTimestamp ||
add(resultsTimestamp, { seconds: freshThreshold }) >= now
);
} }
public async getMediaSeekTime( public async getMediaSeekTime(
@@ -404,18 +579,26 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
return null; return null;
} }
const cameraID = media.getCameraID();
const query: RecordingSegmentsQuery = { const query: RecordingSegmentsQuery = {
cameraID: media.getCameraID(), cameraIDs: new Set([cameraID]),
start: start, start: start,
end: end, end: end,
type: QueryType.RecordingSegments, type: QueryType.RecordingSegments,
}; };
const segments = await this.getRecordingSegments(hass, cameras, query); const results = await this.getRecordingSegments(hass, cameras, query);
const out = segments
? this._getSeekTimeInSegments(start, target, segments.segments) if (results) {
: null; return this._getSeekTimeInSegments(
return out; start,
target,
// There will only be a single result since Frigate recording segments
// searches are per camera which is specified singularly above.
Array.from(results.values())[0].segments,
);
}
return null;
} }
protected _getQueryableCameraConfig( protected _getQueryableCameraConfig(
@@ -438,10 +621,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
): Promise<void> { ): Promise<void> {
const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
const recordingQueries: RecordingQuery[] = cameraIDs.map((cameraID) => ({ const recordingQuery: RecordingQuery = {
cameraID: cameraID, cameraIDs: new Set(cameraIDs),
type: QueryType.Recording, type: QueryType.Recording,
})); };
const countSegments = () => const countSegments = () =>
sum( sum(
@@ -451,19 +634,6 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
); );
const segmentsStart = countSegments(); const segmentsStart = countSegments();
const results: Map<RecordingQuery, FrigateRecordingQueryResults> = new Map();
await Promise.all(
recordingQueries.map((query) =>
(async () => {
const recordings = await this.getRecordings(hass, cameras, query);
if (recordings && recordings.engine === Engine.Frigate) {
results.set(query, recordings as FrigateRecordingQueryResults);
}
})(),
),
);
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M // Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
// items) and each item must be examined, so care required here to stick to // items) and each item must be examined, so care required here to stick to
// nothing worse than O(n) performance. // nothing worse than O(n) performance.
@@ -471,16 +641,28 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`;
}; };
const results = await this.getRecordings(hass, cameras, recordingQuery);
if (!results) {
return;
}
for (const [query, result] of results) { for (const [query, result] of results) {
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) {
continue;
}
const goodHours: Set<string> = new Set(); const goodHours: Set<string> = new Set();
for (const recording of result.recordings) { for (const recording of result.recordings) {
goodHours.add(getHourID(recording.cameraID, recording.startTime)); goodHours.add(getHourID(recording.cameraID, recording.startTime));
} }
// Frigate recordings are always executed individually, so there'll only
// be a single results.
const cameraID = Array.from(query.cameraIDs)[0];
this._recordingSegmentsCache.expireMatches( this._recordingSegmentsCache.expireMatches(
query.cameraID, cameraID,
(segment: RecordingSegment) => { (segment: RecordingSegment) => {
const hourID = getHourID(query.cameraID, fromUnixTime(segment.start_time)); const hourID = getHourID(cameraID, fromUnixTime(segment.start_time));
// ~O(1) lookup time for a JS set. // ~O(1) lookup time for a JS set.
return goodHours.has(hourID); return goodHours.has(hourID);
}, },
+3 -3
View File
@@ -95,9 +95,9 @@ export async function retainEvent(
export interface NativeFrigateEventQuery { export interface NativeFrigateEventQuery {
instance_id?: string; instance_id?: string;
camera?: string; cameras?: string[];
label?: string; labels?: string[];
zone?: string; zones?: string[];
after?: number; after?: number;
before?: number; before?: number;
limit?: number; limit?: number;
+116 -86
View File
@@ -5,6 +5,7 @@ import {
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
EventQueryResultsMap,
MediaQuery, MediaQuery,
PartialDataQuery, PartialDataQuery,
PartialEventQuery, PartialEventQuery,
@@ -17,16 +18,21 @@ import {
QueryType, QueryType,
RecordingQuery, RecordingQuery,
RecordingQueryResults, RecordingQueryResults,
RecordingQueryResultsMap,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResults, RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
} from './types.js'; } from './types.js';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js'; import { ViewMedia } from '../view/media.js';
import { MediaQueriesResults } from '../view/media-queries-results'; import { MediaQueriesResults } from '../view/media-queries-results';
import { MemoryRequestCache } from './cache.js';
import { MediaQueries } from '../view/media-queries.js'; import { MediaQueries } from '../view/media-queries.js';
import uniqBy from 'lodash-es/uniqBy'; import uniqBy from 'lodash-es/uniqBy';
import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum';
import add from 'date-fns/add';
export class QueryClassifier { export class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
@@ -67,27 +73,22 @@ export interface ExtendedMediaQueryResult<T extends MediaQuery> {
results: ViewMedia[]; results: ViewMedia[];
} }
export type RequestCache = MemoryRequestCache<DataQuery, QueryResults>;
export class CameraManager { export class CameraManager {
protected _engineFactory: CameraManagerEngineFactory; protected _engineFactory: CameraManagerEngineFactory;
protected _cameras: Map<string, CameraConfig>; protected _cameras: Map<string, CameraConfig>;
protected _requestCache: RequestCache;
constructor( constructor(
engineFactory: CameraManagerEngineFactory, engineFactory: CameraManagerEngineFactory,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
requestCache: RequestCache,
) { ) {
this._engineFactory = engineFactory; this._engineFactory = engineFactory;
this._cameras = cameras; this._cameras = cameras;
this._requestCache = requestCache;
} }
public generateDefaultEventQueries( public generateDefaultEventQueries(
cameraIDs: string | Set<string>, cameraIDs: string | Set<string>,
partialQuery: PartialEventQuery, partialQuery: PartialEventQuery,
): EventQuery[] { ): EventQuery[] | null {
return this._generateDefaultQueries(cameraIDs, { return this._generateDefaultQueries(cameraIDs, {
...partialQuery, ...partialQuery,
type: QueryType.Event, type: QueryType.Event,
@@ -97,7 +98,7 @@ export class CameraManager {
public generateDefaultRecordingQueries( public generateDefaultRecordingQueries(
cameraIDs: string | Set<string>, cameraIDs: string | Set<string>,
partialQuery: PartialRecordingQuery, partialQuery: PartialRecordingQuery,
): RecordingQuery[] { ): RecordingQuery[] | null {
return this._generateDefaultQueries(cameraIDs, { return this._generateDefaultQueries(cameraIDs, {
...partialQuery, ...partialQuery,
type: QueryType.Recording, type: QueryType.Recording,
@@ -107,73 +108,76 @@ export class CameraManager {
public generateDefaultRecordingSegmentsQueries( public generateDefaultRecordingSegmentsQueries(
cameraIDs: string | Set<string>, cameraIDs: string | Set<string>,
partialQuery: PartialRecordingSegmentsQuery, partialQuery: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] { ): RecordingSegmentsQuery[] | null {
return this._generateDefaultQueries(cameraIDs, { return this._generateDefaultQueries(cameraIDs, {
...partialQuery, ...partialQuery,
type: QueryType.RecordingSegments, type: QueryType.RecordingSegments,
}); });
} }
protected _generateDefaultQueries<PQT extends Partial<DataQuery>>( protected _generateDefaultQueries<PQT extends PartialDataQuery>(
cameraIDs: string | Set<string>, cameraIDs: string | Set<string>,
partialQuery: PQT, partialQuery: PQT,
): PartialQueryConcreteType<PQT>[] { ): PartialQueryConcreteType<PQT>[] | null {
const concreteQueries: PartialQueryConcreteType<PQT>[] = []; const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
const _cameraIDs = setify(cameraIDs); const _cameraIDs = setify(cameraIDs);
_cameraIDs.forEach((cameraID) => { const engines = this._engineFactory.getEnginesForCameraIDs(
const cameraConfig = this._cameras.get(cameraID); this._cameras,
if (!cameraConfig) { _cameraIDs,
return; );
if (!engines) {
return null;
} }
const engine = this._engineFactory.getEngineForCamera(cameraConfig); for (const [engine, cameraIDs] of engines) {
if (!engine) { let queries: DataQuery[] | null = null;
return;
}
let query: DataQuery | null = null;
if (QueryClassifier.isEventQuery(partialQuery)) { if (QueryClassifier.isEventQuery(partialQuery)) {
query = engine.generateDefaultEventQuery(cameraID, cameraConfig, partialQuery); queries = engine.generateDefaultEventQuery(
this._cameras,
cameraIDs,
partialQuery,
);
} else if (QueryClassifier.isRecordingQuery(partialQuery)) { } else if (QueryClassifier.isRecordingQuery(partialQuery)) {
query = engine.generateDefaultRecordingQuery( queries = engine.generateDefaultRecordingQuery(
cameraID, this._cameras,
cameraConfig, cameraIDs,
partialQuery, partialQuery,
); );
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
query = engine.generateDefaultRecordingSegmentsQuery( queries = engine.generateDefaultRecordingSegmentsQuery(
cameraID, this._cameras,
cameraConfig, cameraIDs,
partialQuery, partialQuery,
); );
} }
if (query) { for (const query of queries ?? []) {
concreteQueries.push(query as PartialQueryConcreteType<PQT>); concreteQueries.push(query as PartialQueryConcreteType<PQT>);
} }
}); }
return concreteQueries; return concreteQueries;
} }
public async getEvents( public async getEvents(
hass: HomeAssistant, hass: HomeAssistant,
query: EventQuery | EventQuery[], query: EventQuery | EventQuery[],
): Promise<Map<EventQuery, EventQueryResults>> { ): Promise<EventQueryResultsMap> {
return await this._handleQuery(hass, query); return await this._handleQuery(hass, query);
} }
public async getRecordings( public async getRecordings(
hass: HomeAssistant, hass: HomeAssistant,
query: RecordingQuery | RecordingQuery[], query: RecordingQuery | RecordingQuery[],
): Promise<Map<RecordingQuery, RecordingQueryResults>> { ): Promise<RecordingQueryResultsMap> {
return await this._handleQuery(hass, query); return await this._handleQuery(hass, query);
} }
public async getRecordingSegments( public async getRecordingSegments(
hass: HomeAssistant, hass: HomeAssistant,
query: RecordingSegmentsQuery | RecordingSegmentsQuery[], query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
): Promise<Map<RecordingSegmentsQuery, RecordingSegmentsQueryResults>> { ): Promise<RecordingSegmentsQueryResultsMap> {
return await this._handleQuery(hass, query); return await this._handleQuery(hass, query);
} }
@@ -301,18 +305,31 @@ export class CameraManager {
queries: MediaQueries, queries: MediaQueries,
results: MediaQueriesResults, results: MediaQueriesResults,
): boolean { ): boolean {
const cameraIDs: Set<string> = new Set(); const now = new Date();
(queries.getQueries() ?? []).forEach((query) => cameraIDs.add(query.cameraID)); const resultsTimestamp = results.getResultsTimestamp();
for (const cameraID of cameraIDs) {
const cameraConfig = this._cameras.get(cameraID); if (!resultsTimestamp) {
if (!cameraConfig) {
return false; return false;
} }
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (!engine || !engine.areMediaQueriesResultsFresh(queries, results)) { for (const query of queries.getQueries() ?? []) {
const engines = this._engineFactory.getEnginesForCameraIDs(
this._cameras,
query.cameraIDs,
);
for (const [engine, cameraIDs] of engines ?? []) {
const maxAgeSeconds = engine.getQueryResultMaxAge({
...query,
cameraIDs: cameraIDs,
});
if (
maxAgeSeconds !== null &&
add(resultsTimestamp, { seconds: maxAgeSeconds }) < now
) {
return false; return false;
} }
} }
}
return true; return true;
} }
@@ -344,83 +361,96 @@ export class CameraManager {
): Promise<Map<QT, QueryReturnType<QT>>> { ): Promise<Map<QT, QueryReturnType<QT>>> {
const _queries = arrayify(query); const _queries = arrayify(query);
const results = new Map<QT, QueryReturnType<QT>>(); const results = new Map<QT, QueryReturnType<QT>>();
const queryStartTime = new Date(); const queryStartTime = new Date();
let queryCachedCount = 0;
const processEngineQuery = async (
engine: CameraManagerEngine,
query?: QT,
): Promise<void> => {
if (!query) {
return;
}
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
if (QueryClassifier.isEventQuery(query)) {
engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map<
QT,
QueryReturnType<QT>
> | null;
} else if (QueryClassifier.isRecordingQuery(query)) {
engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map<
QT,
QueryReturnType<QT>
> | null;
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
engineResult = (await engine.getRecordingSegments(
hass,
this._cameras,
query,
)) as Map<QT, QueryReturnType<QT>> | null;
}
engineResult?.forEach((value, key) => results.set(key, value));
};
const processQuery = async (query: QT): Promise<void> => { const processQuery = async (query: QT): Promise<void> => {
const cachedResult: QueryReturnType<QT> | null = this._requestCache.get( const engines = this._engineFactory.getEnginesForCameraIDs(
query, this._cameras,
) as QueryReturnType<QT> | null; query.cameraIDs,
if (cachedResult) { );
queryCachedCount++; if (!engines) {
results.set(query, cachedResult);
return; return;
} }
await Promise.all(
const engine = this._engineFactory.getEngineForQuery(this._cameras, query); Array.from(engines.keys()).map((engine) =>
if (!engine) { processEngineQuery(engine, { ...query, cameraIDs: engines.get(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);
}
// The engine may independently cached the results. Respect that in our
// debug logging.
if (result?.cached) {
queryCachedCount++;
}
if (result) {
if (result.expiry) {
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
}
results.set(query, result as QueryReturnType<QT>);
}
}; };
await Promise.all(_queries.map((query) => processQuery(query))); await Promise.all(_queries.map((query) => processQuery(query)));
const cachedOutputQueries = sum(
Array.from(results.values()).map((result) => Number(result.cached)),
);
console.debug( console.debug(
'Frigate Card CameraManager request (Cached:', 'Frigate Card CameraManager request [Input queries:',
`${queryCachedCount}/${_queries.length},`, _queries.length,
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, ', Cached output queries:',
'Queries:', cachedOutputQueries,
', Total output queries:',
results.size,
', Duration:',
`${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
', Queries:',
_queries, _queries,
', Results:', ', Results:',
results, results,
')', ']',
); );
return results; return results;
} }
protected _convertQueryResultsToMedia<QT extends DataQuery>( protected _convertQueryResultsToMedia<QT extends DataQuery>(
results: Map<QT, QueryReturnType<QT>>, results: ResultsMap<QT>,
): ViewMedia[] { ): ViewMedia[] {
const mediaArray: ViewMedia[] = []; const mediaArray: ViewMedia[] = [];
for (const [query, result] of results.entries()) { for (const [query, result] of results.entries()) {
const cameraConfig = this._cameras.get(query.cameraID); const engine = this._engineFactory.getEngine(result.engine);
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (engine && cameraConfig) { if (engine) {
let media: ViewMedia[] | null = null; let media: ViewMedia[] | null = null;
if ( if (
QueryClassifier.isEventQuery(query) && QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result) QueryResultClassifier.isEventQueryResult(result)
) { ) {
media = engine.generateMediaFromEvents(cameraConfig, query, result); media = engine.generateMediaFromEvents(this._cameras, query, result);
} else if ( } else if (
QueryClassifier.isRecordingQuery(query) && QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result) QueryResultClassifier.isRecordingQuery(result)
) { ) {
media = engine.generateMediaFromRecordings(cameraConfig, query, result); media = engine.generateMediaFromRecordings(this._cameras, query, result);
} }
if (media) { if (media) {
mediaArray.push(...media); mediaArray.push(...media);
+11 -3
View File
@@ -23,7 +23,7 @@ export enum Engine {
export interface DataQuery { export interface DataQuery {
type: QueryType; type: QueryType;
cameraID: string; cameraIDs: Set<string>;
} }
export type PartialDataQuery = Partial<DataQuery>; export type PartialDataQuery = Partial<DataQuery>;
@@ -63,6 +63,11 @@ export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
? RecordingSegmentsQuery ? RecordingSegmentsQuery
: never; : never;
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
export type EventQueryResultsMap = ResultsMap<EventQuery>;
export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>;
export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>;
// =========== // ===========
// Event Query // Event Query
// =========== // ===========
@@ -77,10 +82,10 @@ export interface EventQuery extends MediaQuery {
hasClip?: boolean; hasClip?: boolean;
// Frigate equivalent: label // Frigate equivalent: label
what?: string; what?: Set<string>;
// Frigate equivalent: zone // Frigate equivalent: zone
where?: string; where?: Set<string>;
} }
export type PartialEventQuery = Partial<EventQuery>; export type PartialEventQuery = Partial<EventQuery>;
@@ -121,15 +126,18 @@ export interface RecordingSegmentsQueryResults extends QueryResults {
export interface FrigateEventQueryResults extends EventQueryResults { export interface FrigateEventQueryResults extends EventQueryResults {
engine: Engine.Frigate; engine: Engine.Frigate;
instanceID: string;
events: FrigateEvent[]; events: FrigateEvent[];
} }
export interface FrigateRecordingQueryResults extends RecordingQueryResults { export interface FrigateRecordingQueryResults extends RecordingQueryResults {
engine: Engine.Frigate; engine: Engine.Frigate;
instanceID: string;
recordings: FrigateRecording[]; recordings: FrigateRecording[];
} }
export interface FrigateRecordingSegmentsQueryResults export interface FrigateRecordingSegmentsQueryResults
extends RecordingSegmentsQueryResults { extends RecordingSegmentsQueryResults {
engine: Engine.Frigate; engine: Engine.Frigate;
instanceID: string;
} }
-1
View File
@@ -1106,7 +1106,6 @@ export class FrigateCard extends LitElement {
this._cameraManager = new CameraManager( this._cameraManager = new CameraManager(
new CameraManagerEngineFactory(), new CameraManagerEngineFactory(),
this._cameras, this._cameras,
new RequestCache(),
); );
} }
+7 -4
View File
@@ -429,7 +429,7 @@ export class FrigateCardTimelineCore extends LitElement {
}) // Whether or not to set the timeline window. }) // Whether or not to set the timeline window.
.mergeInContext({ .mergeInContext({
...(canSeek && { mediaViewer: { seek: targetTime } }), ...(canSeek && { mediaViewer: { seek: targetTime } }),
...this._setWindowInContext(properties) ...this._setWindowInContext(properties),
}) })
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
@@ -616,9 +616,12 @@ export class FrigateCardTimelineCore extends LitElement {
options?.window ?? this._timeline.getWindow(), options?.window ?? this._timeline.getWindow(),
); );
return new EventMediaQueries( const eventQueries =
this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow), this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
); if (!eventQueries) {
return null;
}
return new EventMediaQueries(eventQueries);
} }
protected async _createViewWithEventMediaQuery( protected async _createViewWithEventMediaQuery(
+11 -4
View File
@@ -53,12 +53,15 @@ export const createViewForEvents = async (
? options.cameraIDs ? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera)); : new Set(getAllDependentCameras(cameras, view.camera));
const queries = cameraManager.generateDefaultEventQueries(cameraIDs, { const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
...(options?.limit && { limit: options.limit }), ...(options?.limit && { limit: options.limit }),
...(options?.mediaType === 'clips' && { hasClip: true }), ...(options?.mediaType === 'clips' && { hasClip: true }),
...(options?.mediaType === 'snapshots' && { hasSnapshot: true }), ...(options?.mediaType === 'snapshots' && { hasSnapshot: true }),
}); });
query = new EventMediaQueries(queries); if (!eventQueries) {
return null;
}
query = new EventMediaQueries(eventQueries);
} }
let queryResults: MediaQueriesResults | null; let queryResults: MediaQueriesResults | null;
@@ -137,12 +140,16 @@ export const createViewForRecordings = async (
? options.cameraIDs ? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera)); : new Set(getAllDependentCameras(cameras, view.camera));
const queries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
...(options?.start && { start: options.start }), ...(options?.start && { start: options.start }),
...(options?.end && { end: options.end }), ...(options?.end && { end: options.end }),
}); });
const query = new RecordingMediaQueries(queries); if (!recordingQueries) {
return null;
}
const query = new RecordingMediaQueries(recordingQueries);
let queryResults: MediaQueriesResults | null; let queryResults: MediaQueriesResults | null;
try { try {
+17 -13
View File
@@ -7,7 +7,7 @@ import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types';
import { CameraManager } from '../camera/manager'; import { CameraManager } from '../camera/manager';
import { EventQuery } from '../camera/types'; import { EventQuery } from '../camera/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util';
import { EventMediaQueries } from "../view/media-queries"; import { EventMediaQueries } from '../view/media-queries';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range';
import { errorToConsole, ModifyInterface } from './basic.js'; import { errorToConsole, ModifyInterface } from './basic.js';
@@ -85,10 +85,7 @@ export class TimelineDataSource {
} }
} }
public async refresh( public async refresh(hass: HomeAssistant, window: TimelineWindow): Promise<void> {
hass: HomeAssistant,
window: TimelineWindow,
): Promise<void> {
try { try {
await Promise.all([ await Promise.all([
this._refreshEvents(hass, window), this._refreshEvents(hass, window),
@@ -110,7 +107,7 @@ export class TimelineDataSource {
}); });
} }
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] { public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
start: window.start, start: window.start,
end: window.end, end: window.end,
@@ -135,9 +132,11 @@ export class TimelineDataSource {
} }
const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window); const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window);
const query = new EventMediaQueries( const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow)
this.getTimelineEventQueries(cacheFriendlyWindow), if (!eventQueries) {
); return;
}
const query = new EventMediaQueries(eventQueries);
const results = await this._cameraManager.executeMediaQueries(hass, query); const results = await this._cameraManager.executeMediaQueries(hass, query);
for (const media of results?.getResults() ?? []) { for (const media of results?.getResults() ?? []) {
@@ -224,7 +223,7 @@ export class TimelineDataSource {
endCap: true, endCap: true,
}); });
const queries = this._cameraManager.generateDefaultRecordingSegmentsQueries( const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries(
this._cameraIDs, this._cameraIDs,
{ {
start: cacheFriendlyWindow.start, start: cacheFriendlyWindow.start,
@@ -232,17 +231,22 @@ export class TimelineDataSource {
}, },
); );
const results = await this._cameraManager.getRecordingSegments(hass, queries); if (!recordingQueries) {
return;
}
const results = await this._cameraManager.getRecordingSegments(hass, recordingQueries);
const newSegments: Map<string, RecordingSegment[]> = new Map(); const newSegments: Map<string, RecordingSegment[]> = new Map();
for (const [query, result] of results) { for (const [query, result] of results) {
let destination: RecordingSegment[] | undefined = newSegments.get(query.cameraID); for (const cameraID of query.cameraIDs) {
let destination: RecordingSegment[] | undefined = newSegments.get(cameraID);
if (!destination) { if (!destination) {
destination = []; destination = [];
newSegments.set(query.cameraID, destination); newSegments.set(cameraID, destination);
} }
result.segments.forEach((segment) => destination?.push(segment)); result.segments.forEach((segment) => destination?.push(segment));
} }
}
for (const [cameraID, segments] of newSegments.entries()) { for (const [cameraID, segments] of newSegments.entries()) {
const existingRecordings = getExistingRecordingsForCameraID(cameraID); const existingRecordings = getExistingRecordingsForCameraID(cameraID);