Files
advanced-camera-card/src/camera/manager.ts
T

445 lines
13 KiB
TypeScript

import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types.js';
import { arrayify, setify } from '../utils/basic.js';
import {
DataQuery,
EventQuery,
EventQueryResults,
MediaQuery,
PartialDataQuery,
PartialEventQuery,
PartialQueryConcreteType,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResults,
RecordingSegmentsQuery,
RecordingSegmentsQueryResults,
} from './types.js';
import orderBy from 'lodash-es/orderBy';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js';
import { MediaQueriesResults } from '../view/media-queries-results';
import { MemoryRequestCache } from './cache.js';
import { MediaQueries } from '../view/media-queries.js';
import uniqBy from 'lodash-es/uniqBy';
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 interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewMedia[];
}
export type RequestCache = MemoryRequestCache<DataQuery, QueryResults>;
export class CameraManager {
protected _engineFactory: CameraManagerEngineFactory;
protected _cameras: Map<string, CameraConfig>;
protected _requestCache: RequestCache;
constructor(
engineFactory: CameraManagerEngineFactory,
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 executeMediaQueries(
hass: HomeAssistant,
mediaQueries: MediaQueries,
): Promise<MediaQueriesResults | null> {
const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries();
if (!queries) {
return null;
}
const mediaArray = this._convertQueryResultsToMedia(
await this._handleQuery(hass, queries),
);
return new MediaQueriesResults(
mediaArray,
// Select the first (most-recent) item.
mediaArray.length ? 0 : null,
);
}
public async extendMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[],
results: ViewMedia[],
direction: 'earlier' | 'later',
chunkSize: number,
): Promise<ExtendedMediaQueryResult<T> | null> {
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
let output: Date | null = null;
for (const result of results) {
const startTime = result.getStartTime();
if (
startTime &&
(!output ||
(want === 'earliest' && startTime < output) ||
(want === 'latest' && startTime > output))
) {
output = startTime;
}
}
return output;
};
// The queries associated with the chunk to fetch.
const newChunkQueries: T[] = [];
// The re-constituted combined query.
const newCombinedQueries: T[] = [];
for (const query of queries) {
const newChunkQuery = { ...query };
const newCombinedQuery = { ...query };
if (direction === 'later') {
newChunkQuery.start = getTimeFromResults('latest') ?? undefined;
newChunkQuery.end = undefined;
newCombinedQuery.end = undefined;
} else if (direction === 'earlier') {
newChunkQuery.end = getTimeFromResults('earliest') ?? undefined;
newChunkQuery.start = undefined;
newCombinedQuery.start = undefined;
}
newChunkQuery.limit = chunkSize;
newCombinedQuery.limit = (query.limit ?? 0) + chunkSize;
newCombinedQueries.push(newCombinedQuery);
newChunkQueries.push(newChunkQuery);
}
const newChunkMedia = this._convertQueryResultsToMedia(
await this._handleQuery(hass, newChunkQueries),
);
if (!newChunkMedia.length) {
return null;
}
return {
queries: newCombinedQueries,
results: this._sortMedia(results.concat(newChunkMedia)),
};
}
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,
media: ViewMedia,
favorite: boolean,
): Promise<void> {
const cameraConfig = this._cameras.get(media.getCameraID());
if (!cameraConfig) {
return;
}
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (engine) {
const queryStartTime = new Date();
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
console.debug(
'Frigate Card CameraManager favorite request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Media:',
media.getID(),
', Favorite:',
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;
}
public async getMediaSeekTime(
hass: HomeAssistant,
media: ViewMedia,
target: Date,
): Promise<number | null> {
const startTime = media.getStartTime();
const endTime = media.getEndTime();
const cameraConfig = this._cameras.get(media.getCameraID());
if (
!cameraConfig ||
!startTime ||
!endTime ||
target < startTime ||
target > endTime
) {
return null;
}
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null;
}
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);
}
// 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)));
console.debug(
'Frigate Card CameraManager request (Cached:',
`${queryCachedCount}/${_queries.length},`,
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Queries:',
_queries,
', Results:',
results,
')',
);
return results;
}
protected _convertQueryResultsToMedia<QT extends DataQuery>(
results: Map<QT, QueryReturnType<QT>>,
): ViewMedia[] {
const mediaArray: ViewMedia[] = [];
for (const [query, result] of results.entries()) {
const cameraConfig = this._cameras.get(query.cameraID);
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
if (engine && cameraConfig) {
let media: ViewMedia[] | null = null;
if (
QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
) {
media = engine.generateMediaFromEvents(cameraConfig, query, result);
} else if (
QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result)
) {
media = engine.generateMediaFromRecordings(cameraConfig, query, result);
}
if (media) {
mediaArray.push(...media);
}
}
}
return this._sortMedia(mediaArray);
}
protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading with the most recent.
(media) => media.getStartTime(),
'desc',
);
}
}