import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig, CardWideConfig } from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { CameraManagerCameraMetadata, CameraManagerCapabilities, CameraManagerMediaCapabilities, DataQuery, EventQuery, EventQueryResults, EventQueryResultsMap, MediaMetadata, MediaQuery, PartialDataQuery, PartialEventQuery, PartialQueryConcreteType, PartialRecordingQuery, PartialRecordingSegmentsQuery, QueryResults, QueryResultsType, QueryReturnType, QueryType, RecordingQuery, RecordingQueryResults, RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, ResultsMap, } 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 { MediaQueries } from '../view/media-queries.js'; import uniqBy from 'lodash-es/uniqBy'; import { CameraManagerEngine } from './engine.js'; import sum from 'lodash-es/sum'; import add from 'date-fns/add'; import { log } from '../utils/debug.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 interface ExtendedMediaQueryResult { queries: T[]; results: ViewMedia[]; } export class CameraManager { protected _engineFactory: CameraManagerEngineFactory; protected _cameras: Map; protected _cardWideConfig?: CardWideConfig; constructor( engineFactory: CameraManagerEngineFactory, cameras: Map, cardWideConfig?: CardWideConfig, ) { this._engineFactory = engineFactory; this._cameras = cameras; this._cardWideConfig = cardWideConfig; } public generateDefaultEventQueries( cameraIDs: string | Set, partialQuery?: PartialEventQuery, ): EventQuery[] | null { return this._generateDefaultQueries(cameraIDs, { type: QueryType.Event, ...partialQuery, }); } public generateDefaultRecordingQueries( cameraIDs: string | Set, partialQuery?: PartialRecordingQuery, ): RecordingQuery[] | null { return this._generateDefaultQueries(cameraIDs, { type: QueryType.Recording, ...partialQuery, }); } public generateDefaultRecordingSegmentsQueries( cameraIDs: string | Set, partialQuery?: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null { return this._generateDefaultQueries(cameraIDs, { type: QueryType.RecordingSegments, ...partialQuery, }); } public async getMediaMetadata( hass: HomeAssistant, ): Promise { const what: Set = new Set(); const where: Set = new Set(); const days: Set = new Set(); const engines = this._engineFactory.getAllEngines(this._cameras); if (!engines) { return null; } const processMetadata = async (engine: CameraManagerEngine): Promise => { const engineMetadata = await engine.getMediaMetadata(hass, this._cameras); if (engineMetadata) { if (engineMetadata.what) { engineMetadata.what.forEach(what.add, what); } if (engineMetadata.where) { engineMetadata.where.forEach(where.add, where); } if (engineMetadata.days) { engineMetadata.days.forEach(days.add, days); } } } await allPromises(engines, (engine) => processMetadata(engine)); if (!what.size && !where.size && !days.size) { return null; } return { ...(what.size && { what: what }), ...(where.size && { where: where }), ...(days.size && { days: days }), } } protected _generateDefaultQueries( cameraIDs: string | Set, partialQuery: PQT, ): PartialQueryConcreteType[] | null { const concreteQueries: PartialQueryConcreteType[] = []; const _cameraIDs = setify(cameraIDs); const engines = this._engineFactory.getEnginesForCameraIDs( this._cameras, _cameraIDs, ); if (!engines) { return null; } for (const [engine, cameraIDs] of engines) { let queries: DataQuery[] | null = null; if (QueryClassifier.isEventQuery(partialQuery)) { queries = engine.generateDefaultEventQuery( this._cameras, cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingQuery(partialQuery)) { queries = engine.generateDefaultRecordingQuery( this._cameras, cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { queries = engine.generateDefaultRecordingSegmentsQuery( this._cameras, cameraIDs, partialQuery, ); } for (const query of queries ?? []) { 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( hass, 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 extendedQueries: T[] = []; for (const query of queries) { const newChunkQuery = { ...query }; if (direction === 'later') { newChunkQuery.start = getTimeFromResults('latest') ?? undefined; } else if (direction === 'earlier') { newChunkQuery.end = getTimeFromResults('earliest') ?? undefined; } newChunkQuery.limit = chunkSize; extendedQueries.push({ ...query, limit: (query.limit ?? 0) + chunkSize, }); newChunkQueries.push(newChunkQuery); } const newChunkMedia = this._convertQueryResultsToMedia( hass, await this._handleQuery(hass, newChunkQueries), ); if (!newChunkMedia.length) { return null; } return { queries: extendedQueries, 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 getCapabilities(): CameraManagerCapabilities | null { const engines = this._engineFactory.getAllEngines(this._cameras); if (!engines) { return null; } return { canFavoriteEvents: engines.some((engine) => engine.getCapabilities()?.canFavoriteEvents), canFavoriteRecordings: engines.some((engine) => engine.getCapabilities()?.canFavoriteRecordings), } } public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { const engine = this._engineFactory.getEngineForMedia(this._cameras, media); if (!engine) { return null; } return engine.getMediaCapabilities(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); log( this._cardWideConfig, '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 now = new Date(); const resultsTimestamp = results.getResultsTimestamp(); if (!resultsTimestamp) { return false; } 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 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(); const processEngineQuery = async ( engine: CameraManagerEngine, query?: QT, ): Promise => { if (!query) { return; } let engineResult: Map> | null = null; if (QueryClassifier.isEventQuery(query)) { engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map< QT, QueryReturnType > | null; } else if (QueryClassifier.isRecordingQuery(query)) { engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map< QT, QueryReturnType > | null; } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { engineResult = (await engine.getRecordingSegments( hass, this._cameras, query, )) as Map> | null; } engineResult?.forEach((value, key) => results.set(key, value)); }; const processQuery = async (query: QT): Promise => { const engines = this._engineFactory.getEnginesForCameraIDs( this._cameras, query.cameraIDs, ); if (!engines) { return; } await Promise.all( Array.from(engines.keys()).map((engine) => processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }), ), ); }; await Promise.all(_queries.map((query) => processQuery(query))); const cachedOutputQueries = sum( Array.from(results.values()).map((result) => Number(result.cached)), ); log( this._cardWideConfig, 'Frigate Card CameraManager request [Input queries:', _queries.length, ', Cached output queries:', cachedOutputQueries, ', Total output queries:', results.size, ', Duration:', `${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, ', Queries:', _queries, ', Results:', results, ']', ); return results; } protected _convertQueryResultsToMedia( hass: HomeAssistant, results: ResultsMap, ): ViewMedia[] { const mediaArray: ViewMedia[] = []; for (const [query, result] of results.entries()) { const engine = this._engineFactory.getEngine(result.engine); if (engine) { let media: ViewMedia[] | null = null; if ( QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { media = engine.generateMediaFromEvents(hass, this._cameras, query, result); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQuery(result) ) { media = engine.generateMediaFromRecordings(hass, this._cameras, 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', ); } public getCameraMetadata( hass: HomeAssistant, cameraConfig?: CameraConfig, ): CameraManagerCameraMetadata | null { const engine = this._engineFactory.getEngineForCamera(cameraConfig); if (!engine || !cameraConfig) { return null; } return engine.getCameraMetadata(hass, cameraConfig); } }