From edb7a8826663b01aa03ab7dd131f20c53303c9c0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 19:16:30 -0800 Subject: [PATCH] Add moved folder. --- src/camera-manager/cache.ts | 160 ++++ src/camera-manager/engine-factory.ts | 93 ++ src/camera-manager/engine.ts | 105 +++ src/camera-manager/frigate/engine-frigate.ts | 838 ++++++++++++++++++ src/camera-manager/frigate/icon.ts | 20 + .../frigate/media-classifier.ts | 18 + src/camera-manager/frigate/media.ts | 181 ++++ src/camera-manager/frigate/requests.ts | 151 ++++ src/camera-manager/frigate/types.ts | 68 ++ src/camera-manager/frigate/util.ts | 97 ++ src/camera-manager/manager.ts | 554 ++++++++++++ src/camera-manager/range.ts | 125 +++ src/camera-manager/types.ts | 169 ++++ src/camera-manager/util.ts | 39 + 14 files changed, 2618 insertions(+) create mode 100644 src/camera-manager/cache.ts create mode 100644 src/camera-manager/engine-factory.ts create mode 100644 src/camera-manager/engine.ts create mode 100644 src/camera-manager/frigate/engine-frigate.ts create mode 100644 src/camera-manager/frigate/icon.ts create mode 100644 src/camera-manager/frigate/media-classifier.ts create mode 100644 src/camera-manager/frigate/media.ts create mode 100644 src/camera-manager/frigate/requests.ts create mode 100644 src/camera-manager/frigate/types.ts create mode 100644 src/camera-manager/frigate/util.ts create mode 100644 src/camera-manager/manager.ts create mode 100644 src/camera-manager/range.ts create mode 100644 src/camera-manager/types.ts create mode 100644 src/camera-manager/util.ts diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts new file mode 100644 index 00000000..a0d997e7 --- /dev/null +++ b/src/camera-manager/cache.ts @@ -0,0 +1,160 @@ +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; +import sortedUniqBy from 'lodash-es/sortedUniqBy'; +import { RecordingSegment } from '../types'; +import { DateRange, MemoryRangeSet } from './range'; +import { DataQuery, QueryResults } from './types'; + +interface RequestCacheItem { + request: Request; + response: Response; + expires?: Date; +} + +interface CameraManagerCache { + get(request: Request): Response | null; + has(request: Request): boolean; + set(request: Request, response: Response, expiry?: Date): void; +} + +export class MemoryRequestCache + implements CameraManagerCache +{ + protected _data: RequestCacheItem[] = []; + + 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 {} + +export class MemoryRangedCache { + 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; + } + + public size(): number { + return this._data.length; + } + + /** + * Remove old data that matches a given predicate. No change to the covered + * ranges is made, i.e. this is asserting authoritiatively that this data does + * not exist in the current ranges. + * @param predicate A predicate to run on each data element. + */ + public expireMatches(predicate: (data: Data) => boolean): void { + this._data = this._data.filter(predicate); + } +} + +export class RecordingSegmentsCache { + protected _segments: Map> = new Map(); + + public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) { + let cameraSegmentCache: MemoryRangedCache | 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): RecordingSegment[] | null { + return this._segments.get(cameraID)?.get(range) ?? null; + } + + public getCache(cameraID: string): MemoryRangedCache | null { + return this._segments.get(cameraID) ?? null; + } + + public getCameraIDs(): string[] { + return [...this._segments.keys()]; + } + + public expireMatches( + cameraID: string, + func: (segment: RecordingSegment) => boolean, + ): void { + this._segments.get(cameraID)?.expireMatches(func); + } +} diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts new file mode 100644 index 00000000..07295bb8 --- /dev/null +++ b/src/camera-manager/engine-factory.ts @@ -0,0 +1,93 @@ +import { CameraConfig, CardWideConfig } from '../types'; +import { ViewMedia } from '../view/media'; +import { RecordingSegmentsCache, RequestCache } from './cache'; +import { CameraManagerEngine } from './engine'; +import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; +import { Engine } from './types'; + +type CameraManagerEngineCameraIDMap = Map>; + +export class CameraManagerEngineFactory { + protected _engines: Map = new Map(); + protected _cardWideConfig: CardWideConfig; + + constructor(cardWideConfig: CardWideConfig) { + this._cardWideConfig = cardWideConfig; + } + + public getEngine(engine: Engine): CameraManagerEngine | null { + const cachedEngine = this._engines.get(engine); + if (cachedEngine) { + return cachedEngine; + } + let cameraManagerEngine: CameraManagerEngine | null = null; + switch (engine) { + case Engine.Frigate: + cameraManagerEngine = new FrigateCameraManagerEngine( + this._cardWideConfig, + new RecordingSegmentsCache(), + new RequestCache(), + ); + break; + } + if (cameraManagerEngine) { + this._engines.set(engine, cameraManagerEngine); + } + return cameraManagerEngine; + } + + public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { + if (!cameraConfig) { + return null; + } + + let engine: Engine | null = null; + if (cameraConfig.frigate.camera_name) { + engine = Engine.Frigate; + } + return engine ? this.getEngine(engine) : null; + } + + public getEnginesForCameraIDs( + cameras: Map, + cameraIDs: Set, + ): 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.size ? output : null; + } + + public getEngineForMedia( + cameras: Map, + media: ViewMedia, + ): CameraManagerEngine | null { + const cameraID = media.getCameraID(); + if (!cameraID) { + return null; + } + const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID])); + return engines ? ([...engines.keys()][0] ?? null) : null; + } + + public getAllEngines( + cameras: Map, + ): CameraManagerEngine[] | null { + const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys())); + return engines ? [...engines.keys()] : null; + } +} diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts new file mode 100644 index 00000000..22fc836f --- /dev/null +++ b/src/camera-manager/engine.ts @@ -0,0 +1,105 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../types'; +import { ViewMedia } from '../view/media'; +import { + DataQuery, + EventQuery, + EventQueryResultsMap, + MediaMetadata, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryReturnType, + RecordingQuery, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, + CameraManagerCameraMetadata, +} from './types'; + +export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; + +export interface CameraManagerEngine { + generateDefaultEventQuery( + cameras: Map, + cameraIDs: Set, + query: PartialEventQuery, + ): EventQuery[] | null; + + generateDefaultRecordingQuery( + cameras: Map, + cameraIDs: Set, + query: PartialRecordingQuery, + ): RecordingQuery[] | null; + + generateDefaultRecordingSegmentsQuery( + cameras: Map, + cameraIDs: Set, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null; + + getEvents( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + ): Promise; + + getRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + ): Promise; + + getRecordingSegments( + hass: HomeAssistant, + cameras: Map, + query: RecordingSegmentsQuery, + ): Promise; + + generateMediaFromEvents( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + generateMediaFromRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null; + + favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise; + + getQueryResultMaxAge(query: DataQuery): number | null; + + getMediaSeekTime( + hass: HomeAssistant, + cameras: Map, + media: ViewMedia, + target: Date, + ): Promise; + + getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise; + + getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata; + + getCapabilities(): CameraManagerEngineCapabilities | null; + getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; +} diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts new file mode 100644 index 00000000..3e7c6b5b --- /dev/null +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -0,0 +1,838 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import add from 'date-fns/add'; +import endOfHour from 'date-fns/endOfHour'; +import startOfHour from 'date-fns/startOfHour'; +import { CAMERA_BIRDSEYE } from '../../const'; +import { CameraConfig, CardWideConfig, RecordingSegment } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { RequestCache, RecordingSegmentsCache } from '../cache'; +import { + CameraManagerEngine, + CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, +} from '../engine'; +import { DateRange } from '../range'; +import { + CameraManagerCameraMetadata, + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, + DataQuery, + Engine, + EventQuery, + EventQueryResults, + EventQueryResultsMap, + FrigateEventQueryResults, + FrigateRecordingQueryResults, + FrigateRecordingSegmentsQueryResults, + MediaMetadata, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryResults, + QueryResultsType, + QueryReturnType, + QueryType, + RecordingQuery, + RecordingQueryResults, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, +} from '../types'; +import { FrigateRecording } from './types'; +import { + getEvents, + getEventSummary, + getRecordingSegments, + getRecordingsSummary, + NativeFrigateEventQuery, + NativeFrigateRecordingSegmentsQuery, + retainEvent, +} from './requests'; +import orderBy from 'lodash-es/orderBy'; +import throttle from 'lodash-es/throttle'; +import { + allPromises, + formatDate, + prettifyTitle, + runWhenIdleIfSupported, +} from '../../utils/basic'; +import { fromUnixTime } from 'date-fns'; +import { sum } from 'lodash-es'; +import { FrigateViewMediaClassifier } from './media-classifier'; +import { ViewMediaClassifier } from '../../view/media-classifier'; +import { FrigateViewMediaFactory } from './media'; +import { log } from '../../utils/debug'; +import { getEntityIcon, getEntityTitle } from '../../utils/ha'; + +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 FrigateCameraManagerEngine implements CameraManagerEngine { + protected _recordingSegmentsCache: RecordingSegmentsCache; + protected _requestCache: RequestCache; + protected _cardWideConfig: CardWideConfig; + + // Garbage collect segments at most once an hour. + protected _throttledSegmentGarbageCollector = throttle( + this._garbageCollectSegments.bind(this), + 60 * 60 * 1000, + { leading: false, trailing: true }, + ); + + constructor( + cardWideConfig: CardWideConfig, + recordingSegmentsCache: RecordingSegmentsCache, + requestCache: RequestCache, + ) { + this._cardWideConfig = cardWideConfig; + this._recordingSegmentsCache = recordingSegmentsCache; + this._requestCache = requestCache; + } + + public getMediaDownloadPath( + cameraConfig: CameraConfig, + media: ViewMedia, + ): string | null { + let path: string | null = null; + if (FrigateViewMediaClassifier.isFrigateEvent(media)) { + path = + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/notifications/${media.getID()}/` + + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + + `?download=true`; + } else if (FrigateViewMediaClassifier.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( + cameras: Map, + cameraIDs: Set, + query?: PartialEventQuery, + ): EventQuery[] | null { + 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, + cameraIDs: cameraIDs, + ...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( + _cameras: Map, + cameraIDs: Set, + query?: PartialRecordingQuery, + ): RecordingQuery[] { + return [ + { + type: QueryType.Recording, + cameraIDs: cameraIDs, + ...query, + }, + ]; + } + + public generateDefaultRecordingSegmentsQuery( + _cameras: Map, + cameraIDs: Set, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null { + if (!query.start || !query.end) { + return null; + } + return [ + { + type: QueryType.RecordingSegments, + cameraIDs: cameraIDs, + start: query.start, + end: query.end, + ...query, + }, + ]; + } + + public async favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise { + if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { + return; + } + + await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite); + media.setFavorite(favorite); + } + + protected _buildInstanceToCameraIDMapFromQuery( + cameras: Map, + cameraIDs: Set, + ): Map> { + const output: Map> = new Map(); + for (const cameraID of 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, + cameraIDs: Set, + ): Set { + const output = new Set(); + 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( + hass: HomeAssistant, + cameras: Map, + query: EventQuery, + ): Promise { + const output: EventQueryResultsMap = new Map(); + + const processInstanceQuery = async ( + instanceID: string, + cameraIDs?: Set, + ): Promise => { + 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 = { + instance_id: instanceID, + cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), + ...(query.what && { labels: Array.from(query.what) }), + ...(query.where && { zones: Array.from(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 }), + ...(query.favorite && { favorites: query.favorite }), + limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + }; + + const result: FrigateEventQueryResults = { + type: QueryResultsType.Event, + engine: Engine.Frigate, + instanceID: instanceID, + events: await getEvents(hass, nativeQuery), + expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), + 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.cameraIDs, + ); + + await Promise.all( + Array.from(instances.keys()).map((instanceID) => + processInstanceQuery(instanceID, instances.get(instanceID)), + ), + ); + return output.size ? output : null; + } + + public async getRecordings( + hass: HomeAssistant, + cameras: Map, + query: RecordingQuery, + ): Promise { + const output: RecordingQueryResultsMap = new Map(); + + const processQuery = async ( + baseQuery: RecordingQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; + const cachedResult = this._requestCache.get(query); + if (cachedResult) { + output.set(query, cachedResult as RecordingQueryResults); + return; + } + + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } + + const recordingSummary = await getRecordingsSummary( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ); + + let 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({ + cameraID: cameraID, + startTime: startHour, + endTime: endHour, + events: hourData.events, + }); + } + } + } + + if (query.limit !== undefined) { + // Frigate does not natively support a way to limit recording searches so + // this simulates it. + recordings = orderBy( + recordings, + (recording: FrigateRecording) => recording.startTime, + 'desc', + ).slice(0, query.limit); + } + + const result: FrigateRecordingQueryResults = { + type: QueryResultsType.Recording, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + recordings: recordings, + expiry: add(new Date(), { + seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, + }), + 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, cameraID)), + ); + return output.size ? output : null; + } + + public async getRecordingSegments( + hass: HomeAssistant, + cameras: Map, + query: RecordingSegmentsQuery, + ): Promise { + const output: RecordingSegmentsQueryResultsMap = new Map(); + + const processQuery = async ( + baseQuery: RecordingSegmentsQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } + + const range: DateRange = { start: query.start, end: query.end }; + + // A note on Frigate Recording Segments: + // - 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(cameraID, range); + if (cachedSegments) { + output.set(query, { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + segments: cachedSegments, + cached: true, + }); + return; + } + + 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), + }; + + const segments = await getRecordingSegments(hass, request); + this._recordingSegmentsCache.add(cameraID, range, segments); + + output.set(query, { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + segments: segments, + 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, cameraID)), + ); + + runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); + return output.size ? output : null; + } + + protected _getCameraIDMatch( + cameras: Map, + 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( + _hass: HomeAssistant, + cameras: Map, + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + 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; + 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 = FrigateViewMediaFactory.createEventViewMedia( + mediaType, + cameraID, + cameraConfig, + event, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public generateMediaFromRecordings( + hass: HomeAssistant, + cameras: Map, + _query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const recording of results.recordings) { + const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID); + if (!cameraConfig) { + continue; + } + const media = FrigateViewMediaFactory.createRecordingViewMedia( + recording.cameraID, + recording, + cameraConfig, + this.getCameraMetadata(hass, cameraConfig).title, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public getQueryResultMaxAge(query: DataQuery): number | null { + if (query.type === QueryType.Event) { + return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; + } else if (query.type === QueryType.Recording) { + return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; + } + return null; + } + + public async getMediaSeekTime( + hass: HomeAssistant, + cameras: Map, + media: ViewMedia, + target: Date, + ): Promise { + const start = media.getStartTime(); + const end = media.getEndTime(); + if (!start || !end || target < start || target > end) { + return null; + } + + const cameraID = media.getCameraID(); + const query: RecordingSegmentsQuery = { + cameraIDs: new Set([cameraID]), + start: start, + end: end, + type: QueryType.RecordingSegments, + }; + + const results = await this.getRecordingSegments(hass, cameras, query); + + if (results) { + return this._getSeekTimeInSegments( + 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( + cameras: Map, + cameraID: string, + ): CameraConfig | null { + const cameraConfig = cameras.get(cameraID); + if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) { + return null; + } + return cameraConfig; + } + + public async getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise { + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + new Set(cameras.keys()), + ); + + const processEventSummary = async ( + instanceID: string, + cameraIDs: Set, + ): Promise => { + const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs); + for (const entry of await getEventSummary(hass, instanceID)) { + if (!cameraNames.has(entry.camera)) { + // If this entry applies to a camera that *is* in this Frigate + // instance, but is *not* a configured camera in the card, skip it. + continue; + } + if (entry.label) { + what.add(entry.label); + } + if (entry.zones.length) { + entry.zones.forEach(where.add, where); + } + if (entry.day) { + days.add(entry.day); + } + } + }; + + const processRecordings = async (cameraIDs: Set): Promise => { + const recordings = await this.getRecordings(hass, cameras, { + type: QueryType.Recording, + cameraIDs: cameraIDs, + }); + if (!recordings) { + return; + } + + for (const result of recordings.values()) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + + for (const recording of result.recordings) { + // Frigate recordings are always 1 hour long, i.e. never span a day. + days.add(formatDate(recording.startTime)); + } + } + }; + + await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => + (async () => { + await Promise.all([ + processEventSummary(instanceID, cameraIDs), + processRecordings(cameraIDs), + ]); + })(), + ); + + if (!what.size && !where.size && !days.size) { + return null; + } + return { + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + }; + } + + /** + * Garbage collect recording segments that no longer feature in the recordings + * returned by the Frigate backend. + */ + protected async _garbageCollectSegments( + hass: HomeAssistant, + cameras: Map, + ): Promise { + const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); + const recordingQuery: RecordingQuery = { + cameraIDs: new Set(cameraIDs), + type: QueryType.Recording, + }; + + const countSegments = () => + sum( + cameraIDs.map( + (cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0, + ), + ); + const segmentsStart = countSegments(); + + // Performance: _recordingSegments is potentially very large (e.g. 10K - 1M + // items) and each item must be examined, so care required here to stick to + // nothing worse than O(n) performance. + const getHourID = (cameraID: string, startTime: Date): string => { + return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; + }; + + const results = await this.getRecordings(hass, cameras, recordingQuery); + if (!results) { + return; + } + + for (const [query, result] of results) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + + const goodHours: Set = new Set(); + for (const recording of result.recordings) { + 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( + cameraID, + (segment: RecordingSegment) => { + const hourID = getHourID(cameraID, fromUnixTime(segment.start_time)); + // ~O(1) lookup time for a JS set. + return goodHours.has(hourID); + }, + ); + } + + log( + this._cardWideConfig, + 'Frigate Card recording segment garbage collection: ' + + `Released ${segmentsStart - countSegments()} segment(s)`, + ); + } + + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param startTime The earliest allowable time to seek from. + * @param targetTime Target time. + * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. + * @returns + */ + protected _getSeekTimeInSegments( + startTime: Date, + targetTime: Date, + segments: RecordingSegment[], + ): number | null { + if (!segments.length) { + return null; + } + let seekMilliseconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + const segmentStart = fromUnixTime(segment.start_time); + if (segmentStart > targetTime) { + break; + } + const segmentEnd = fromUnixTime(segment.end_time); + const start = segmentStart < startTime ? startTime : segmentStart; + const end = segmentEnd > targetTime ? targetTime : segmentEnd; + seekMilliseconds += end.getTime() - start.getTime(); + } + return seekMilliseconds / 1000; + } + + public getCapabilities(): CameraManagerEngineCapabilities { + return { + canFavoriteEvents: true, + canFavoriteRecordings: false, + }; + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { + return { + canFavorite: ViewMediaClassifier.isEvent(media), + }; + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + return { + title: + cameraConfig.title ?? + getEntityTitle(hass, cameraConfig.camera_entity) ?? + getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? + prettifyTitle(cameraConfig.frigate?.camera_name) ?? + cameraConfig.id ?? + '', + icon: + cameraConfig?.icon ?? + getEntityIcon(hass, cameraConfig.camera_entity) ?? + 'mdi:video', + }; + } +} diff --git a/src/camera-manager/frigate/icon.ts b/src/camera-manager/frigate/icon.ts new file mode 100644 index 00000000..3e075f2e --- /dev/null +++ b/src/camera-manager/frigate/icon.ts @@ -0,0 +1,20 @@ +export const FRIGATE_ICON_SVG_PATH = + 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + + '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' + + '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' + + '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' + + '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' + + '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' + + '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' + + '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' + + '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' + + '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' + + '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' + + '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' + + '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' + + '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' + + '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' + + 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' + + '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' + + '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' + + '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z'; diff --git a/src/camera-manager/frigate/media-classifier.ts b/src/camera-manager/frigate/media-classifier.ts new file mode 100644 index 00000000..442d6b0d --- /dev/null +++ b/src/camera-manager/frigate/media-classifier.ts @@ -0,0 +1,18 @@ +import { ViewMedia } from '../../view/media'; +import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; + +export class FrigateViewMediaClassifier { + public static isFrigateMedia( + media: ViewMedia, + ): media is FrigateEventViewMedia | FrigateRecordingViewMedia { + return this.isFrigateEvent(media) || this.isFrigateRecording(media); + } + public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia { + return media instanceof FrigateEventViewMedia; + } + public static isFrigateRecording( + media: ViewMedia, + ): media is FrigateRecordingViewMedia { + return media instanceof FrigateRecordingViewMedia; + } +} diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts new file mode 100644 index 00000000..0b5f5a3e --- /dev/null +++ b/src/camera-manager/frigate/media.ts @@ -0,0 +1,181 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import fromUnixTime from 'date-fns/fromUnixTime'; +import isEqual from 'lodash-es/isEqual'; +import { CameraConfig } from '../../types'; +import { + ViewMedia, + EventViewMedia, + RecordingViewMedia, + ViewMediaType, +} from '../../view/media'; +import { FrigateEvent, FrigateRecording } from './types'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingID, + getRecordingMediaContentID, + getRecordingTitle, +} from './util'; + +export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { + protected _event: FrigateEvent; + protected _contentID: string; + protected _thumbnail: string; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + event: FrigateEvent, + contentID: string, + thumbnail: string, + ) { + super(mediaType, cameraID); + this._event = event; + this._contentID = contentID; + this._thumbnail = thumbnail; + } + + public hasClip(): boolean { + return !!this._event.has_clip; + } + public getStartTime(): Date { + return fromUnixTime(this._event.start_time); + } + public getEndTime(): Date | null { + return this._event.end_time ? fromUnixTime(this._event.end_time) : null; + } + public getID(): string { + return this._event.id; + } + public getContentID(): string { + return this._contentID; + } + public getTitle(): string | null { + return getEventTitle(this._event); + } + public getThumbnail(): string | null { + return this._thumbnail; + } + public isFavorite(): boolean | null { + return this._event.retain_indefinitely ?? null; + } + public setFavorite(favorite: boolean): void { + this._event.retain_indefinitely = favorite; + } + public getWhat(): string[] | null { + return [this._event.label]; + } + public getWhere(): string[] | null { + const zones = this._event.zones; + return zones.length ? zones : null; + } + public getScore(): number | null { + return this._event.top_score; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } +} + +export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia { + protected _recording: FrigateRecording; + protected _id: string; + protected _contentID: string; + protected _title: string; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + recording: FrigateRecording, + id: string, + contentID: string, + title: string, + ) { + super(mediaType, cameraID); + this._recording = recording; + this._id = id; + this._contentID = contentID; + this._title = title; + } + + public getID(): string { + return this._id; + } + public getStartTime(): Date { + return this._recording.startTime; + } + public getEndTime(): Date { + return this._recording.endTime; + } + public getContentID(): string | null { + return this._contentID; + } + public getTitle(): string | null { + return this._title; + } + public getEventCount(): number { + return this._recording.events; + } +} + +export class FrigateViewMediaFactory { + static createEventViewMedia( + mediaType: 'clip' | 'snapshot', + cameraID: string, + cameraConfig: CameraConfig, + event: FrigateEvent, + ): FrigateEventViewMedia | null { + if ( + (mediaType === 'clip' && !event.has_clip) || + (mediaType === 'snapshot' && !event.has_snapshot) || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name + ) { + return null; + } + + return new FrigateEventViewMedia( + mediaType, + cameraID, + event, + getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + event, + mediaType === 'clip' ? 'clips' : 'snapshots', + ), + getEventThumbnailURL(cameraConfig.frigate.client_id, event), + ); + } + + static createRecordingViewMedia( + cameraID: string, + recording: FrigateRecording, + cameraConfig: CameraConfig, + cameraTitle: string, + ): FrigateRecordingViewMedia | null { + if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) { + return null; + } + + return new FrigateRecordingViewMedia( + 'recording', + cameraID, + recording, + getRecordingID(cameraConfig, recording), + getRecordingMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + recording, + ), + getRecordingTitle(cameraTitle, recording), + ); + } +} diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts new file mode 100644 index 00000000..01d48878 --- /dev/null +++ b/src/camera-manager/frigate/requests.ts @@ -0,0 +1,151 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from '../../localize/localize'; +import { FrigateCardError, RecordingSegment } from '../../types'; +import { homeAssistantWSRequest } from '../../utils/ha'; +import { + EventSummary, + eventSummarySchema, + FrigateEvent, + frigateEventsSchema, + recordingSegmentsSchema, + RecordingSummary, + recordingSummarySchema, + RetainResult, + retainResultSchema, +} from './types'; + +/** + * Get the recordings summary. May throw. + * @param hass The Home Assistant object. + * @param clientID The Frigate clientID. + * @param camera_name The Frigate camera name. + * @returns A RecordingSummary object. + */ +export const getRecordingsSummary = async ( + hass: HomeAssistant, + clientID: string, + camera_name: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSummarySchema, + { + type: 'frigate/recordings/summary', + instance_id: clientID, + camera: camera_name, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + ); +}; + +export interface NativeFrigateRecordingSegmentsQuery { + instance_id: string; + camera: string; + after: number; + before: number; +} + +/** + * Get the recording segments. May throw. + * @param hass The Home Assistant object. + * @param params The recording segment query parameters. + * @returns A RecordingSegments object. + */ +export const getRecordingSegments = async ( + hass: HomeAssistant, + params: NativeFrigateRecordingSegmentsQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSegmentsSchema, + { + type: 'frigate/recordings/get', + ...params, + }, + true, + ); +}; + +/** + * Request that Frigate retain an event. May throw. + * @param hass The HomeAssistant object. + * @param clientID The Frigate clientID. + * @param eventID The event ID to retain. + * @param retain `true` to retain or `false` to unretain. + */ +export async function retainEvent( + hass: HomeAssistant, + clientID: string, + eventID: string, + retain: boolean, +): Promise { + const retainRequest = { + type: 'frigate/event/retain', + instance_id: clientID, + event_id: eventID, + retain: retain, + }; + const response = await homeAssistantWSRequest( + hass, + retainResultSchema, + retainRequest, + true, + ); + if (!response.success) { + throw new FrigateCardError(localize('error.failed_retain'), { + request: retainRequest, + response: response, + }); + } +} + +export interface NativeFrigateEventQuery { + instance_id?: string; + cameras?: string[]; + labels?: string[]; + zones?: string[]; + after?: number; + before?: number; + limit?: number; + has_clip?: boolean; + has_snapshot?: boolean; + favorites?: boolean; +} + +/** + * Get events over websocket. May throw. + * @param hass The Home Assistant object. + * @param params The events search parameters. + * @returns An array of 'FrigateEvent's. + */ +export const getEvents = async ( + hass: HomeAssistant, + params?: NativeFrigateEventQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateEventsSchema, + { + type: 'frigate/events/get', + ...params, + }, + true, + ); +}; + +export const getEventSummary = async ( + hass: HomeAssistant, + clientID: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + eventSummarySchema, + { + type: 'frigate/events/summary', + instance_id: clientID, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + ); +}; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts new file mode 100644 index 00000000..41a0cd6b --- /dev/null +++ b/src/camera-manager/frigate/types.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { dayToDate } from '../../utils/basic'; + +const dayStringToDate = (arg: unknown): Date | unknown => { + return typeof arg === 'string' ? dayToDate(arg) : arg; +}; + +export const eventSchema = z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean().nullable(), + has_clip: z.boolean(), + has_snapshot: z.boolean(), + id: z.string(), + label: z.string(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + retain_indefinitely: z.boolean().optional(), +}); +export const frigateEventsSchema = eventSchema.array(); + +export type FrigateEvent = z.infer; + +const recordingSummaryHourSchema = z.object({ + hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), + duration: z.number().min(0), + events: z.number().min(0), +}); + +export const recordingSummarySchema = z + .object({ + day: z.preprocess(dayStringToDate, z.date()), + events: z.number(), + hours: recordingSummaryHourSchema.array(), + }) + .array(); +export type RecordingSummary = z.infer; + +const recordingSegmentSchema = z.object({ + start_time: z.number(), + end_time: z.number(), + id: z.string(), +}); +export const recordingSegmentsSchema = recordingSegmentSchema.array(); + +export const retainResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type RetainResult = z.infer; + +export interface FrigateRecording { + cameraID: string; + startTime: Date; + endTime: Date; + events: number; +} + +export const eventSummarySchema = z + .object({ + camera: z.string(), + day: z.string(), + label: z.string(), + zones: z.string().array(), + }) + .array(); +export type EventSummary = z.infer; diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts new file mode 100644 index 00000000..0be47429 --- /dev/null +++ b/src/camera-manager/frigate/util.ts @@ -0,0 +1,97 @@ +import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; +import { CameraConfig, ClipsOrSnapshots } from '../../types'; +import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; +import { FrigateEvent, FrigateRecording } from './types'; + +/** + * Given an event generate a title. + * @param event + */ +export const getEventTitle = (event: FrigateEvent): string => { + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const durationSeconds = Math.round( + event.end_time + ? event.end_time - event.start_time + : Date.now() / 1000 - event.start_time, + ); + return `${formatDateAndTime( + utcToZonedTime(event.start_time * 1000, localTimezone), + )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( + event.top_score * 100, + )}%]`; +}; + +export const getRecordingTitle = ( + cameraTitle: string, + recording: FrigateRecording, +): string => { + return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`; +}; + +/** + * Get a thumbnail URL for an event. + * @param clientId The Frigate client id. + * @param event The event. + * @returns A string URL. + */ +export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { + return `/api/frigate/${clientId}/thumbnail/${event.id}`; +}; + +/** + * Get a media content ID for an event. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param event The Frigate event. + * @param mediaType The media type required. + * @returns A string media content id. + */ +export const getEventMediaContentID = ( + clientId: string, + cameraName: string, + event: FrigateEvent, + mediaType: ClipsOrSnapshots, +): string => { + return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; +}; + +/** + * Generate a recording identifier. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param recording The Frigate recording. + * @returns A recording identifier. + */ +export const getRecordingMediaContentID = ( + clientId: string, + cameraName: string, + recording: FrigateRecording, +): string => { + return [ + 'media-source://frigate', + clientId, + 'recordings', + cameraName, + `${recording.startTime.getFullYear()}-${String( + recording.startTime.getMonth() + 1, + ).padStart(2, '0')}-${String( + String(recording.startTime.getDate()).padStart(2, '0'), + )}`, + String(recording.startTime.getHours()).padStart(2, '0'), + ].join('/'); +}; + +/** + * Get a recording ID for internal de-duping. + */ +export const getRecordingID = ( + cameraConfig: CameraConfig, + recording: FrigateRecording, +): string => { + // ID name is derived from the real camera name (not CameraID) since the + // recordings for the same camera across multiple zones will be the same and + // can be dedup'd from this id. + return `${cameraConfig.frigate?.client_id ?? ''}/${ + cameraConfig.frigate.camera_name ?? '' + }/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`; +}; diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts new file mode 100644 index 00000000..6c04cdfa --- /dev/null +++ b/src/camera-manager/manager.ts @@ -0,0 +1,554 @@ +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); + } +} diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts new file mode 100644 index 00000000..6c3b7666 --- /dev/null +++ b/src/camera-manager/range.ts @@ -0,0 +1,125 @@ +import orderBy from 'lodash-es/orderBy'; + +interface Range { + start: T; + end: T; +} + +export type DateRange = Range; + +interface MemoryRangeSetInterface { + hasCoverage(range: T): boolean; + add(range: T): void; + clear(): void; +} + +export class MemoryRangeSet implements MemoryRangeSetInterface { + 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 extends Range { + expires: Date; +} + +export class ExpiringMemoryRangeSet + implements MemoryRangeSetInterface> +{ + protected _ranges: ExpiringRange[]; + + constructor(ranges?: ExpiringRange[]) { + 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): 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 = ( + ranges: Range[], + toleranceSeconds = 0, +): Range[] => { + const compressedRanges: Range[] = []; + ranges = orderBy(ranges, (range) => range.start, 'asc'); + + let current: Range | 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; +}; diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts new file mode 100644 index 00000000..c7ca2380 --- /dev/null +++ b/src/camera-manager/types.ts @@ -0,0 +1,169 @@ +import { RecordingSegment } from '../types'; +import { FrigateEvent, FrigateRecording } from './frigate/types'; + +// ==== +// 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; + cameraIDs: Set; +} +export type PartialDataQuery = Partial; + +export interface TimeBasedDataQuery { + start: Date; + end: Date; +} + +export interface LimitedDataQuery { + limit: number; +} + +export interface MediaQuery + extends DataQuery, + Partial, + Partial {} + +export interface QueryResults { + type: QueryResultsType; + engine: Engine; + expiry?: Date; + cached?: boolean; +} + +export type QueryReturnType = QT extends EventQuery + ? EventQueryResults + : QT extends RecordingQuery + ? RecordingQueryResults + : QT extends RecordingSegmentsQuery + ? RecordingSegmentsQueryResults + : never; +export type PartialQueryConcreteType = PQT extends PartialEventQuery + ? EventQuery + : PQT extends PartialRecordingQuery + ? RecordingQuery + : PQT extends PartialRecordingSegmentsQuery + ? RecordingSegmentsQuery + : never; + +export type ResultsMap = Map>; +export type EventQueryResultsMap = ResultsMap; +export type RecordingQueryResultsMap = ResultsMap; +export type RecordingSegmentsQueryResultsMap = ResultsMap; + +export interface MediaMetadata { + where?: Set; + what?: Set; + days?: Set; +} + +interface BaseCapabilities { + canFavoriteEvents: boolean; + canFavoriteRecordings: boolean; +} + +export type CameraManagerCapabilities = BaseCapabilities; +export type CameraManagerEngineCapabilities = BaseCapabilities; +export interface CameraManagerMediaCapabilities { + canFavorite: boolean; +} + +export interface CameraManagerCameraMetadata { + title: string; + icon: string; +} + +// =========== +// 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?: Set; + + // Frigate equivalent: zone + where?: Set; + + favorite?: boolean; +} +export type PartialEventQuery = Partial; + +export interface EventQueryResults extends QueryResults { + type: QueryResultsType.Event; +} + +// =============== +// Recording Query +// =============== + +export interface RecordingQuery extends MediaQuery { + type: QueryType.Recording; + + favorite?: boolean; +} +export type PartialRecordingQuery = Partial; + +export interface RecordingQueryResults extends QueryResults { + type: QueryResultsType.Recording; +} + +// ======================== +// Recording Segments Query +// ======================== + +export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery { + type: QueryType.RecordingSegments; +} +export type PartialRecordingSegmentsQuery = Partial; + +export interface RecordingSegmentsQueryResults extends QueryResults { + type: QueryResultsType.RecordingSegments; + segments: RecordingSegment[]; +} + +// ======================== +// Frigate concrete results +// ======================== + +export interface FrigateEventQueryResults extends EventQueryResults { + engine: Engine.Frigate; + instanceID: string; + events: FrigateEvent[]; +} + +export interface FrigateRecordingQueryResults extends RecordingQueryResults { + engine: Engine.Frigate; + instanceID: string; + recordings: FrigateRecording[]; +} + +export interface FrigateRecordingSegmentsQueryResults + extends RecordingSegmentsQueryResults { + engine: Engine.Frigate; + instanceID: string; +} diff --git a/src/camera-manager/util.ts b/src/camera-manager/util.ts new file mode 100644 index 00000000..3f4693cd --- /dev/null +++ b/src/camera-manager/util.ts @@ -0,0 +1,39 @@ +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 { DateRange } from './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 { + cacheableStart = startOfDay(range.start); + cacheableEnd = endOfDay(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; +};