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 { queries: T[]; results: ViewMedia[]; } export type RequestCache = MemoryRequestCache; export class CameraManager { protected _engineFactory: CameraManagerEngineFactory; protected _cameras: Map; protected _requestCache: RequestCache; constructor( engineFactory: CameraManagerEngineFactory, cameras: Map, requestCache: RequestCache, ) { this._engineFactory = engineFactory; this._cameras = cameras; this._requestCache = requestCache; } public generateDefaultEventQueries( cameraIDs: string | Set, partialQuery: PartialEventQuery, ): EventQuery[] { return this._generateDefaultQueries(cameraIDs, { ...partialQuery, type: QueryType.Event, }); } public generateDefaultRecordingQueries( cameraIDs: string | Set, partialQuery: PartialRecordingQuery, ): RecordingQuery[] { return this._generateDefaultQueries(cameraIDs, { ...partialQuery, type: QueryType.Recording, }); } public generateDefaultRecordingSegmentsQueries( cameraIDs: string | Set, partialQuery: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] { return this._generateDefaultQueries(cameraIDs, { ...partialQuery, type: QueryType.RecordingSegments, }); } protected _generateDefaultQueries>( cameraIDs: string | Set, partialQuery: PQT, ): PartialQueryConcreteType[] { const concreteQueries: PartialQueryConcreteType[] = []; 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); } }); return concreteQueries; } public async getEvents( hass: HomeAssistant, query: EventQuery | EventQuery[], ): Promise> { return await this._handleQuery(hass, query); } public async getRecordings( hass: HomeAssistant, query: RecordingQuery | RecordingQuery[], ): Promise> { return await this._handleQuery(hass, query); } public async getRecordingSegments( hass: HomeAssistant, query: RecordingSegmentsQuery | RecordingSegmentsQuery[], ): Promise> { return await this._handleQuery(hass, query); } public async executeMediaQueries( hass: HomeAssistant, mediaQueries: MediaQueries, ): Promise { 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( hass: HomeAssistant, queries: T[], results: ViewMedia[], direction: 'earlier' | 'later', chunkSize: number, ): Promise | 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 { 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 = 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 { 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( hass: HomeAssistant, query: QT | QT[], ): Promise>> { const _queries = arrayify(query); const results = new Map>(); const queryStartTime = new Date(); let queryCachedCount = 0; const processQuery = async (query: QT): Promise => { const cachedResult: QueryReturnType | null = this._requestCache.get( query, ) as QueryReturnType | 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); } }; 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( results: Map>, ): 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', ); } }