Add moved folder.
This commit is contained in:
@@ -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, Response> {
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
expires?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CameraManagerCache<Request, Response> {
|
||||||
|
get(request: Request): Response | null;
|
||||||
|
has(request: Request): boolean;
|
||||||
|
set(request: Request, response: Response, expiry?: Date): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryRequestCache<Request, Response>
|
||||||
|
implements CameraManagerCache<Request, Response>
|
||||||
|
{
|
||||||
|
protected _data: RequestCacheItem<Request, Response>[] = [];
|
||||||
|
|
||||||
|
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<DataQuery, QueryResults> {}
|
||||||
|
|
||||||
|
export class MemoryRangedCache<Data> {
|
||||||
|
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<string, MemoryRangedCache<RecordingSegment>> = new Map();
|
||||||
|
|
||||||
|
public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) {
|
||||||
|
let cameraSegmentCache: MemoryRangedCache<RecordingSegment> | 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<RecordingSegment> | 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CameraManagerEngine, Set<string>>;
|
||||||
|
|
||||||
|
export class CameraManagerEngineFactory {
|
||||||
|
protected _engines: Map<Engine, CameraManagerEngine> = 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<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
): 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<string, CameraConfig>,
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
): CameraManagerEngine[] | null {
|
||||||
|
const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys()));
|
||||||
|
return engines ? [...engines.keys()] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
query: PartialEventQuery,
|
||||||
|
): EventQuery[] | null;
|
||||||
|
|
||||||
|
generateDefaultRecordingQuery(
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
query: PartialRecordingQuery,
|
||||||
|
): RecordingQuery[] | null;
|
||||||
|
|
||||||
|
generateDefaultRecordingSegmentsQuery(
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
query: PartialRecordingSegmentsQuery,
|
||||||
|
): RecordingSegmentsQuery[] | null;
|
||||||
|
|
||||||
|
getEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: EventQuery,
|
||||||
|
): Promise<EventQueryResultsMap | null>;
|
||||||
|
|
||||||
|
getRecordings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: RecordingQuery,
|
||||||
|
): Promise<RecordingQueryResultsMap | null>;
|
||||||
|
|
||||||
|
getRecordingSegments(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: RecordingSegmentsQuery,
|
||||||
|
): Promise<RecordingSegmentsQueryResultsMap | null>;
|
||||||
|
|
||||||
|
generateMediaFromEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: EventQuery,
|
||||||
|
results: QueryReturnType<EventQuery>,
|
||||||
|
): ViewMedia[] | null;
|
||||||
|
|
||||||
|
generateMediaFromRecordings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
query: RecordingQuery,
|
||||||
|
results: QueryReturnType<RecordingQuery>,
|
||||||
|
): ViewMedia[] | null;
|
||||||
|
|
||||||
|
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
|
||||||
|
|
||||||
|
favoriteMedia(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
favorite: boolean,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
getQueryResultMaxAge(query: DataQuery): number | null;
|
||||||
|
|
||||||
|
getMediaSeekTime(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
media: ViewMedia,
|
||||||
|
target: Date,
|
||||||
|
): Promise<number | null>;
|
||||||
|
|
||||||
|
getMediaMetadata(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
): Promise<MediaMetadata | null>;
|
||||||
|
|
||||||
|
getCameraMetadata(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
): CameraManagerCameraMetadata;
|
||||||
|
|
||||||
|
getCapabilities(): CameraManagerEngineCapabilities | null;
|
||||||
|
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
|
||||||
|
}
|
||||||
@@ -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<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
query?: PartialRecordingQuery,
|
||||||
|
): RecordingQuery[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: QueryType.Recording,
|
||||||
|
cameraIDs: cameraIDs,
|
||||||
|
...query,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultRecordingSegmentsQuery(
|
||||||
|
_cameras: Map<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
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<void> {
|
||||||
|
if (!FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite);
|
||||||
|
media.setFavorite(favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _buildInstanceToCameraIDMapFromQuery(
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
): Map<string, Set<string>> {
|
||||||
|
const output: Map<string, Set<string>> = 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<string, CameraConfig>,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
): Set<string> {
|
||||||
|
const output = new Set<string>();
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
query: EventQuery,
|
||||||
|
): Promise<EventQueryResultsMap | null> {
|
||||||
|
const output: EventQueryResultsMap = new Map();
|
||||||
|
|
||||||
|
const processInstanceQuery = async (
|
||||||
|
instanceID: string,
|
||||||
|
cameraIDs?: Set<string>,
|
||||||
|
): Promise<void> => {
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
query: RecordingQuery,
|
||||||
|
): Promise<RecordingQueryResultsMap | null> {
|
||||||
|
const output: RecordingQueryResultsMap = new Map();
|
||||||
|
|
||||||
|
const processQuery = async (
|
||||||
|
baseQuery: RecordingQuery,
|
||||||
|
cameraID: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
query: RecordingSegmentsQuery,
|
||||||
|
): Promise<RecordingSegmentsQueryResultsMap | null> {
|
||||||
|
const output: RecordingSegmentsQueryResultsMap = new Map();
|
||||||
|
|
||||||
|
const processQuery = async (
|
||||||
|
baseQuery: RecordingSegmentsQuery,
|
||||||
|
cameraID: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
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, <FrigateRecordingSegmentsQueryResults>{
|
||||||
|
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, <FrigateRecordingSegmentsQueryResults>{
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
query: EventQuery,
|
||||||
|
results: QueryReturnType<EventQuery>,
|
||||||
|
): 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<string, CameraConfig>,
|
||||||
|
_query: RecordingQuery,
|
||||||
|
results: QueryReturnType<RecordingQuery>,
|
||||||
|
): 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<string, CameraConfig>,
|
||||||
|
media: ViewMedia,
|
||||||
|
target: Date,
|
||||||
|
): Promise<number | null> {
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
): Promise<MediaMetadata | null> {
|
||||||
|
const what: Set<string> = new Set();
|
||||||
|
const where: Set<string> = new Set();
|
||||||
|
const days: Set<string> = new Set();
|
||||||
|
|
||||||
|
const instances = this._buildInstanceToCameraIDMapFromQuery(
|
||||||
|
cameras,
|
||||||
|
new Set(cameras.keys()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const processEventSummary = async (
|
||||||
|
instanceID: string,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
): Promise<void> => {
|
||||||
|
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<string>): Promise<void> => {
|
||||||
|
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<string, CameraConfig>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string> = 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RecordingSummary> => {
|
||||||
|
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<RecordingSegment[]> => {
|
||||||
|
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<void> {
|
||||||
|
const retainRequest = {
|
||||||
|
type: 'frigate/event/retain',
|
||||||
|
instance_id: clientID,
|
||||||
|
event_id: eventID,
|
||||||
|
retain: retain,
|
||||||
|
};
|
||||||
|
const response = await homeAssistantWSRequest<RetainResult>(
|
||||||
|
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<FrigateEvent[]> => {
|
||||||
|
return await homeAssistantWSRequest(
|
||||||
|
hass,
|
||||||
|
frigateEventsSchema,
|
||||||
|
{
|
||||||
|
type: 'frigate/events/get',
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEventSummary = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
clientID: string,
|
||||||
|
): Promise<EventSummary> => {
|
||||||
|
return await homeAssistantWSRequest(
|
||||||
|
hass,
|
||||||
|
eventSummarySchema,
|
||||||
|
{
|
||||||
|
type: 'frigate/events/summary',
|
||||||
|
instance_id: clientID,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<typeof eventSchema>;
|
||||||
|
|
||||||
|
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<typeof recordingSummarySchema>;
|
||||||
|
|
||||||
|
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<typeof retainResultSchema>;
|
||||||
|
|
||||||
|
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<typeof eventSummarySchema>;
|
||||||
@@ -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()}}`;
|
||||||
|
};
|
||||||
@@ -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<T extends MediaQuery> {
|
||||||
|
queries: T[];
|
||||||
|
results: ViewMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CameraManager {
|
||||||
|
protected _engineFactory: CameraManagerEngineFactory;
|
||||||
|
protected _cameras: Map<string, CameraConfig>;
|
||||||
|
protected _cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
engineFactory: CameraManagerEngineFactory,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
cardWideConfig?: CardWideConfig,
|
||||||
|
) {
|
||||||
|
this._engineFactory = engineFactory;
|
||||||
|
this._cameras = cameras;
|
||||||
|
this._cardWideConfig = cardWideConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultEventQueries(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery?: PartialEventQuery,
|
||||||
|
): EventQuery[] | null {
|
||||||
|
return this._generateDefaultQueries(cameraIDs, {
|
||||||
|
type: QueryType.Event,
|
||||||
|
...partialQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultRecordingQueries(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery?: PartialRecordingQuery,
|
||||||
|
): RecordingQuery[] | null {
|
||||||
|
return this._generateDefaultQueries(cameraIDs, {
|
||||||
|
type: QueryType.Recording,
|
||||||
|
...partialQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultRecordingSegmentsQueries(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery?: PartialRecordingSegmentsQuery,
|
||||||
|
): RecordingSegmentsQuery[] | null {
|
||||||
|
return this._generateDefaultQueries(cameraIDs, {
|
||||||
|
type: QueryType.RecordingSegments,
|
||||||
|
...partialQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaMetadata(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
): Promise<MediaMetadata | null> {
|
||||||
|
const what: Set<string> = new Set();
|
||||||
|
const where: Set<string> = new Set();
|
||||||
|
const days: Set<string> = new Set();
|
||||||
|
|
||||||
|
const engines = this._engineFactory.getAllEngines(this._cameras);
|
||||||
|
if (!engines) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processMetadata = async (engine: CameraManagerEngine): Promise<void> => {
|
||||||
|
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<PQT extends PartialDataQuery>(
|
||||||
|
cameraIDs: string | Set<string>,
|
||||||
|
partialQuery: PQT,
|
||||||
|
): PartialQueryConcreteType<PQT>[] | null {
|
||||||
|
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
||||||
|
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<PQT>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return concreteQueries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: EventQuery | EventQuery[],
|
||||||
|
): Promise<EventQueryResultsMap> {
|
||||||
|
return await this._handleQuery(hass, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecordings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: RecordingQuery | RecordingQuery[],
|
||||||
|
): Promise<RecordingQueryResultsMap> {
|
||||||
|
return await this._handleQuery(hass, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecordingSegments(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
|
||||||
|
): Promise<RecordingSegmentsQueryResultsMap> {
|
||||||
|
return await this._handleQuery(hass, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async executeMediaQueries(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mediaQueries: MediaQueries,
|
||||||
|
): Promise<MediaQueriesResults | null> {
|
||||||
|
const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries();
|
||||||
|
if (!queries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mediaArray = this._convertQueryResultsToMedia(
|
||||||
|
hass,
|
||||||
|
await this._handleQuery(hass, queries),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new MediaQueriesResults(
|
||||||
|
mediaArray,
|
||||||
|
|
||||||
|
// Select the first (most-recent) item.
|
||||||
|
mediaArray.length ? 0 : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async extendMediaQueries<T extends MediaQuery>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
queries: T[],
|
||||||
|
results: ViewMedia[],
|
||||||
|
direction: 'earlier' | 'later',
|
||||||
|
chunkSize: number,
|
||||||
|
): Promise<ExtendedMediaQueryResult<T> | null> {
|
||||||
|
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
|
||||||
|
let output: Date | null = null;
|
||||||
|
for (const result of results) {
|
||||||
|
const startTime = result.getStartTime();
|
||||||
|
if (
|
||||||
|
startTime &&
|
||||||
|
(!output ||
|
||||||
|
(want === 'earliest' && startTime < output) ||
|
||||||
|
(want === 'latest' && startTime > output))
|
||||||
|
) {
|
||||||
|
output = startTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The queries associated with the chunk to fetch.
|
||||||
|
const newChunkQueries: T[] = [];
|
||||||
|
|
||||||
|
// The re-constituted combined query.
|
||||||
|
const 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<void> {
|
||||||
|
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||||
|
if (engine) {
|
||||||
|
const queryStartTime = new Date();
|
||||||
|
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
|
||||||
|
|
||||||
|
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<number | null> {
|
||||||
|
const startTime = media.getStartTime();
|
||||||
|
const endTime = media.getEndTime();
|
||||||
|
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||||
|
if (
|
||||||
|
!cameraConfig ||
|
||||||
|
!startTime ||
|
||||||
|
!endTime ||
|
||||||
|
target < startTime ||
|
||||||
|
target > endTime
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||||
|
return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _handleQuery<QT extends DataQuery>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
query: QT | QT[],
|
||||||
|
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||||
|
const _queries = arrayify(query);
|
||||||
|
const results = new Map<QT, QueryReturnType<QT>>();
|
||||||
|
const queryStartTime = new Date();
|
||||||
|
|
||||||
|
const processEngineQuery = async (
|
||||||
|
engine: CameraManagerEngine,
|
||||||
|
query?: QT,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
||||||
|
if (QueryClassifier.isEventQuery(query)) {
|
||||||
|
engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map<
|
||||||
|
QT,
|
||||||
|
QueryReturnType<QT>
|
||||||
|
> | null;
|
||||||
|
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||||
|
engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map<
|
||||||
|
QT,
|
||||||
|
QueryReturnType<QT>
|
||||||
|
> | null;
|
||||||
|
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||||
|
engineResult = (await engine.getRecordingSegments(
|
||||||
|
hass,
|
||||||
|
this._cameras,
|
||||||
|
query,
|
||||||
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
engineResult?.forEach((value, key) => results.set(key, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const processQuery = async (query: QT): Promise<void> => {
|
||||||
|
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<QT extends DataQuery>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
results: ResultsMap<QT>,
|
||||||
|
): 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import orderBy from 'lodash-es/orderBy';
|
||||||
|
|
||||||
|
interface Range<T extends Date | number> {
|
||||||
|
start: T;
|
||||||
|
end: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateRange = Range<Date>;
|
||||||
|
|
||||||
|
interface MemoryRangeSetInterface<T> {
|
||||||
|
hasCoverage(range: T): boolean;
|
||||||
|
add(range: T): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryRangeSet implements MemoryRangeSetInterface<DateRange> {
|
||||||
|
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<T extends Date | number> extends Range<T> {
|
||||||
|
expires: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpiringMemoryRangeSet
|
||||||
|
implements MemoryRangeSetInterface<ExpiringRange<Date>>
|
||||||
|
{
|
||||||
|
protected _ranges: ExpiringRange<Date>[];
|
||||||
|
|
||||||
|
constructor(ranges?: ExpiringRange<Date>[]) {
|
||||||
|
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<Date>): 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 = <T extends Date | number>(
|
||||||
|
ranges: Range<T>[],
|
||||||
|
toleranceSeconds = 0,
|
||||||
|
): Range<T>[] => {
|
||||||
|
const compressedRanges: Range<T>[] = [];
|
||||||
|
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||||
|
|
||||||
|
let current: Range<T> | 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;
|
||||||
|
};
|
||||||
@@ -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<string>;
|
||||||
|
}
|
||||||
|
export type PartialDataQuery = Partial<DataQuery>;
|
||||||
|
|
||||||
|
export interface TimeBasedDataQuery {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitedDataQuery {
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaQuery
|
||||||
|
extends DataQuery,
|
||||||
|
Partial<TimeBasedDataQuery>,
|
||||||
|
Partial<LimitedDataQuery> {}
|
||||||
|
|
||||||
|
export interface QueryResults {
|
||||||
|
type: QueryResultsType;
|
||||||
|
engine: Engine;
|
||||||
|
expiry?: Date;
|
||||||
|
cached?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryReturnType<QT> = QT extends EventQuery
|
||||||
|
? EventQueryResults
|
||||||
|
: QT extends RecordingQuery
|
||||||
|
? RecordingQueryResults
|
||||||
|
: QT extends RecordingSegmentsQuery
|
||||||
|
? RecordingSegmentsQueryResults
|
||||||
|
: never;
|
||||||
|
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
||||||
|
? EventQuery
|
||||||
|
: PQT extends PartialRecordingQuery
|
||||||
|
? RecordingQuery
|
||||||
|
: PQT extends PartialRecordingSegmentsQuery
|
||||||
|
? RecordingSegmentsQuery
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
|
||||||
|
export type EventQueryResultsMap = ResultsMap<EventQuery>;
|
||||||
|
export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>;
|
||||||
|
export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>;
|
||||||
|
|
||||||
|
export interface MediaMetadata {
|
||||||
|
where?: Set<string>;
|
||||||
|
what?: Set<string>;
|
||||||
|
days?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>;
|
||||||
|
|
||||||
|
// Frigate equivalent: zone
|
||||||
|
where?: Set<string>;
|
||||||
|
|
||||||
|
favorite?: boolean;
|
||||||
|
}
|
||||||
|
export type PartialEventQuery = Partial<EventQuery>;
|
||||||
|
|
||||||
|
export interface EventQueryResults extends QueryResults {
|
||||||
|
type: QueryResultsType.Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============
|
||||||
|
// Recording Query
|
||||||
|
// ===============
|
||||||
|
|
||||||
|
export interface RecordingQuery extends MediaQuery {
|
||||||
|
type: QueryType.Recording;
|
||||||
|
|
||||||
|
favorite?: boolean;
|
||||||
|
}
|
||||||
|
export type PartialRecordingQuery = Partial<RecordingQuery>;
|
||||||
|
|
||||||
|
export interface RecordingQueryResults extends QueryResults {
|
||||||
|
type: QueryResultsType.Recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Recording Segments Query
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
|
||||||
|
type: QueryType.RecordingSegments;
|
||||||
|
}
|
||||||
|
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user