From 730eb429834d891dac1ac4ce37085fa3d66c3fe7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 19:02:24 -0800 Subject: [PATCH] Rename camera directory to camera-manager. --- src/camera/cache.ts | 160 ----- src/camera/engine-factory.ts | 93 --- src/camera/engine.ts | 105 ---- src/camera/frigate/engine-frigate.ts | 838 ------------------------- src/camera/frigate/icon.ts | 20 - src/camera/frigate/media-classifier.ts | 18 - src/camera/frigate/media.ts | 181 ------ src/camera/frigate/requests.ts | 151 ----- src/camera/frigate/types.ts | 68 -- src/camera/frigate/util.ts | 97 --- src/camera/manager.ts | 554 ---------------- src/camera/range.ts | 125 ---- src/camera/types.ts | 169 ----- src/camera/util.ts | 39 -- src/card.ts | 4 +- src/components/gallery.ts | 4 +- src/components/live/live.ts | 2 +- src/components/media-filter-core.ts | 2 +- src/components/media-filter.ts | 6 +- src/components/menu.ts | 2 +- src/components/surround.ts | 2 +- src/components/thumbnail-carousel.ts | 2 +- src/components/thumbnail.ts | 2 +- src/components/timeline-core.ts | 4 +- src/components/timeline.ts | 2 +- src/components/viewer.ts | 2 +- src/editor.ts | 2 +- src/types.ts | 2 +- src/utils/ha/browse-media.ts | 2 +- src/utils/media-to-view.ts | 2 +- src/utils/timeline-source.ts | 8 +- src/view/media-queries.ts | 2 +- 32 files changed, 26 insertions(+), 2644 deletions(-) delete mode 100644 src/camera/cache.ts delete mode 100644 src/camera/engine-factory.ts delete mode 100644 src/camera/engine.ts delete mode 100644 src/camera/frigate/engine-frigate.ts delete mode 100644 src/camera/frigate/icon.ts delete mode 100644 src/camera/frigate/media-classifier.ts delete mode 100644 src/camera/frigate/media.ts delete mode 100644 src/camera/frigate/requests.ts delete mode 100644 src/camera/frigate/types.ts delete mode 100644 src/camera/frigate/util.ts delete mode 100644 src/camera/manager.ts delete mode 100644 src/camera/range.ts delete mode 100644 src/camera/types.ts delete mode 100644 src/camera/util.ts diff --git a/src/camera/cache.ts b/src/camera/cache.ts deleted file mode 100644 index a0d997e7..00000000 --- a/src/camera/cache.ts +++ /dev/null @@ -1,160 +0,0 @@ -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/engine-factory.ts b/src/camera/engine-factory.ts deleted file mode 100644 index 07295bb8..00000000 --- a/src/camera/engine-factory.ts +++ /dev/null @@ -1,93 +0,0 @@ -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/engine.ts b/src/camera/engine.ts deleted file mode 100644 index 22fc836f..00000000 --- a/src/camera/engine.ts +++ /dev/null @@ -1,105 +0,0 @@ -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/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts deleted file mode 100644 index 3e7c6b5b..00000000 --- a/src/camera/frigate/engine-frigate.ts +++ /dev/null @@ -1,838 +0,0 @@ -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/frigate/icon.ts b/src/camera/frigate/icon.ts deleted file mode 100644 index 3e075f2e..00000000 --- a/src/camera/frigate/icon.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/frigate/media-classifier.ts b/src/camera/frigate/media-classifier.ts deleted file mode 100644 index 442d6b0d..00000000 --- a/src/camera/frigate/media-classifier.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/frigate/media.ts b/src/camera/frigate/media.ts deleted file mode 100644 index 0b5f5a3e..00000000 --- a/src/camera/frigate/media.ts +++ /dev/null @@ -1,181 +0,0 @@ -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/frigate/requests.ts b/src/camera/frigate/requests.ts deleted file mode 100644 index 01d48878..00000000 --- a/src/camera/frigate/requests.ts +++ /dev/null @@ -1,151 +0,0 @@ -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/frigate/types.ts b/src/camera/frigate/types.ts deleted file mode 100644 index 41a0cd6b..00000000 --- a/src/camera/frigate/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -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/frigate/util.ts b/src/camera/frigate/util.ts deleted file mode 100644 index 0be47429..00000000 --- a/src/camera/frigate/util.ts +++ /dev/null @@ -1,97 +0,0 @@ -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.ts b/src/camera/manager.ts deleted file mode 100644 index 6c04cdfa..00000000 --- a/src/camera/manager.ts +++ /dev/null @@ -1,554 +0,0 @@ -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/range.ts b/src/camera/range.ts deleted file mode 100644 index 6c3b7666..00000000 --- a/src/camera/range.ts +++ /dev/null @@ -1,125 +0,0 @@ -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/types.ts b/src/camera/types.ts deleted file mode 100644 index c7ca2380..00000000 --- a/src/camera/types.ts +++ /dev/null @@ -1,169 +0,0 @@ -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/util.ts b/src/camera/util.ts deleted file mode 100644 index 3f4693cd..00000000 --- a/src/camera/util.ts +++ /dev/null @@ -1,39 +0,0 @@ -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; -}; diff --git a/src/card.ts b/src/card.ts index df7d5e37..91d236a1 100644 --- a/src/card.ts +++ b/src/card.ts @@ -91,9 +91,9 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { View } from './view/view.js'; import pkg from '../package.json'; import { ViewContext } from 'view'; -import { CameraManager } from './camera/manager.js'; +import { CameraManager } from './camera-manager/manager.js'; import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; -import { CameraManagerEngineFactory } from './camera/engine-factory.js'; +import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; import { log } from './utils/debug.js'; /** A note on media callbacks: diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 217667b3..46c000bb 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -22,7 +22,7 @@ import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; -import { CameraManager, ExtendedMediaQueryResult } from '../camera/manager.js'; +import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; import { View } from '../view/view.js'; import { renderProgressIndicator } from './message.js'; import './thumbnail.js'; @@ -30,7 +30,7 @@ import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { createRef, Ref } from 'lit/directives/ref.js'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; -import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types'; import { MediaQueriesResults } from '../view/media-queries-results'; import { errorToConsole } from '../utils/basic'; import './media-filter'; diff --git a/src/components/live/live.ts b/src/components/live/live.ts index f21d736b..e7f68134 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -50,7 +50,7 @@ import '../surround.js'; import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js'; import { classMap } from 'lit/directives/class-map.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; -import { CameraManager } from '../../camera/manager.js'; +import { CameraManager } from '../../camera-manager/manager.js'; import { HomeAssistant } from 'custom-card-helpers'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { HassEntity } from 'home-assistant-js-websocket'; diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 6d1c8bf1..365b560d 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -8,7 +8,7 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { DateRange } from '../camera/range'; +import { DateRange } from '../camera-manager/range'; import { localize } from '../localize/localize'; import mediaFilterCoreStyle from '../scss/media-filter-core.scss'; import { ExtendedHomeAssistant } from '../types'; diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index d1a81e50..63493265 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -16,8 +16,8 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { CameraManager } from '../camera/manager'; -import { DateRange } from '../camera/range'; +import { CameraManager } from '../camera-manager/manager'; +import { DateRange } from '../camera-manager/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { View } from '../view/view'; import { @@ -31,7 +31,7 @@ import { } from './media-filter-core'; import './surround.js'; import './timeline-core.js'; -import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera/types'; +import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera-manager/types'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js'; import { HomeAssistant } from 'custom-card-helpers'; diff --git a/src/components/menu.ts b/src/components/menu.ts index 758b1a93..4c04b78d 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -27,7 +27,7 @@ import { frigateCardHasAction, getActionConfigGivenAction } from '../utils/action.js'; -import { FRIGATE_ICON_SVG_PATH } from '../camera/frigate/icon.js'; +import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js'; import { refreshDynamicStateParameters } from '../utils/ha'; import './submenu.js'; diff --git a/src/components/surround.ts b/src/components/surround.ts index c67d6e43..1044c972 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -16,7 +16,7 @@ import { ThumbnailsControlConfig, } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; import { View } from '../view/view.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 0f7d75ac..4e9625b7 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -25,7 +25,7 @@ import { FrigateCardCarousel } from './carousel.js'; import './thumbnail.js'; import './carousel.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; export interface ThumbnailCarouselTap { queryResults: MediaQueriesResults; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index e7256366..35f4b429 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -23,7 +23,7 @@ import { Task, TaskStatus } from '@lit-labs/task'; import type { CameraConfig, ExtendedHomeAssistant } from '../types.js'; import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; import { ViewMediaClassifier } from '../view/media-classifier.js'; // The minimum width of a thumbnail with details enabled. diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 58cdb705..037b6712 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -49,7 +49,7 @@ import { createViewForRecordings, findClosestMediaIndex, } from '../utils/media-to-view'; -import { CameraManager } from '../camera/manager'; +import { CameraManager } from '../camera-manager/manager'; import { EventMediaQueries, MediaQueries } from '../view/media-queries'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { dispatchMessageEvent } from './message.js'; @@ -57,7 +57,7 @@ import './thumbnail.js'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; -import { rangesOverlap } from '../camera/range'; +import { rangesOverlap } from '../camera-manager/range'; import { View } from '../view/view'; interface FrigateCardGroupData { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 581167fa..24618d4c 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import timelineStyle from '../scss/timeline.scss'; import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; -import { CameraManager } from '../camera/manager'; +import { CameraManager } from '../camera-manager/manager'; import { View } from '../view/view'; import './surround.js'; import './timeline-core.js'; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 3c53afe1..de203459 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -47,7 +47,7 @@ import '../patches/ha-hls-player'; import './surround.js'; import { renderTask } from '../utils/task.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; -import { CameraManager } from '../camera/manager.js'; +import { CameraManager } from '../camera-manager/manager.js'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, diff --git a/src/editor.ts b/src/editor.ts index bb5fa1b1..29ae6893 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -142,7 +142,7 @@ import { } from './types.js'; import { arrayMove, prettifyTitle } from './utils/basic.js'; import { getCameraID } from './utils/camera.js'; -import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/icon.js'; +import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js'; import { getEntitiesFromHASS, getEntityTitle, diff --git a/src/types.ts b/src/types.ts index 221372ca..57d0b471 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,7 @@ import { } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; -import { eventSchema, FrigateEvent, FrigateRecording } from './camera/frigate/types.js'; +import { eventSchema, FrigateEvent, FrigateRecording } from './camera-manager/frigate/types.js'; import { deepRemoveDefaults } from './utils/zod.js'; // The min allowed size of buttons. diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 1128c19c..b78de684 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -1,6 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; import { homeAssistantWSRequest } from '.'; -import { FrigateEvent, FrigateRecording } from '../../camera/frigate/types'; +import { FrigateEvent, FrigateRecording } from '../../camera-manager/frigate/types'; import { dispatchErrorMessageEvent } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import { diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 7d7d9fe0..7e3aa277 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -8,7 +8,7 @@ import { MediaQueries, RecordingMediaQueries, } from '../view/media-queries'; -import { CameraManager } from '../camera/manager'; +import { CameraManager } from '../camera-manager/manager'; import { getAllDependentCameras } from './camera.js'; import { ViewMedia } from '../view/media'; import { HomeAssistant } from 'custom-card-helpers'; diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 81d5cd41..cd749ba7 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -4,12 +4,12 @@ import sub from 'date-fns/sub'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types'; -import { CameraManager } from '../camera/manager'; -import { EventQuery } from '../camera/types'; -import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util'; +import { CameraManager } from '../camera-manager/manager'; +import { EventQuery } from '../camera-manager/types'; +import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { EventMediaQueries } from '../view/media-queries'; import { ViewMedia } from '../view/media'; -import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range'; +import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera-manager/range'; import { errorToConsole, ModifyInterface } from './basic.js'; // Allow timeline freshness to be at least this number of seconds out of date diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index abdb8784..6ba2dfab 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -1,6 +1,6 @@ import isEqual from 'lodash-es/isEqual'; import cloneDeep from 'lodash-es/cloneDeep.js'; -import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types.js'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; export type MediaQueries = EventMediaQueries | RecordingMediaQueries;