Rename camera directory to camera-manager.

This commit is contained in:
Dermot Duffy
2023-01-29 19:02:24 -08:00
parent 0df6faaf7d
commit 730eb42983
32 changed files with 26 additions and 2644 deletions
-160
View File
@@ -1,160 +0,0 @@
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import sortedUniqBy from 'lodash-es/sortedUniqBy';
import { RecordingSegment } from '../types';
import { DateRange, MemoryRangeSet } from './range';
import { DataQuery, QueryResults } from './types';
interface RequestCacheItem<Request, 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);
}
}
-93
View File
@@ -1,93 +0,0 @@
import { CameraConfig, CardWideConfig } from '../types';
import { ViewMedia } from '../view/media';
import { RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine';
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
import { Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<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;
}
}
-105
View File
@@ -1,105 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types';
import { ViewMedia } from '../view/media';
import {
DataQuery,
EventQuery,
EventQueryResultsMap,
MediaMetadata,
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
QueryReturnType,
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
CameraManagerEngineCapabilities,
CameraManagerMediaCapabilities,
CameraManagerCameraMetadata,
} from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
export interface CameraManagerEngine {
generateDefaultEventQuery(
cameras: Map<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;
}
-838
View File
@@ -1,838 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour';
import startOfHour from 'date-fns/startOfHour';
import { CAMERA_BIRDSEYE } from '../../const';
import { CameraConfig, CardWideConfig, RecordingSegment } from '../../types';
import { ViewMedia } from '../../view/media';
import { RequestCache, RecordingSegmentsCache } from '../cache';
import {
CameraManagerEngine,
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
} from '../engine';
import { DateRange } from '../range';
import {
CameraManagerCameraMetadata,
CameraManagerEngineCapabilities,
CameraManagerMediaCapabilities,
DataQuery,
Engine,
EventQuery,
EventQueryResults,
EventQueryResultsMap,
FrigateEventQueryResults,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
MediaMetadata,
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResults,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
} from '../types';
import { FrigateRecording } from './types';
import {
getEvents,
getEventSummary,
getRecordingSegments,
getRecordingsSummary,
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
retainEvent,
} from './requests';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import {
allPromises,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
} from '../../utils/basic';
import { fromUnixTime } from 'date-fns';
import { sum } from 'lodash-es';
import { FrigateViewMediaClassifier } from './media-classifier';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { FrigateViewMediaFactory } from './media';
import { log } from '../../utils/debug';
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
class FrigateQueryResultsClassifier {
public static isFrigateEventQueryResults(
results: QueryResults,
): results is FrigateEventQueryResults {
return results.engine === Engine.Frigate && results.type === QueryResultsType.Event;
}
public static isFrigateRecordingQueryResults(
results: QueryResults,
): results is FrigateRecordingQueryResults {
return (
results.engine === Engine.Frigate && results.type === QueryResultsType.Recording
);
}
public static isFrigateRecordingSegmentsResults(
results: QueryResults,
): results is FrigateRecordingSegmentsQueryResults {
return (
results.engine === Engine.Frigate &&
results.type === QueryResultsType.RecordingSegments
);
}
}
export class FrigateCameraManagerEngine implements CameraManagerEngine {
protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: RequestCache;
protected _cardWideConfig: CardWideConfig;
// Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle(
this._garbageCollectSegments.bind(this),
60 * 60 * 1000,
{ leading: false, trailing: true },
);
constructor(
cardWideConfig: CardWideConfig,
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache,
) {
this._cardWideConfig = cardWideConfig;
this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
}
public getMediaDownloadPath(
cameraConfig: CameraConfig,
media: ViewMedia,
): string | null {
let path: string | null = null;
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
path =
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/notifications/${media.getID()}/` +
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`;
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
path =
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/recording/${cameraConfig.frigate.camera_name}` +
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
`?download=true`;
}
return path;
}
public generateDefaultEventQuery(
cameras: Map<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',
};
}
}
-20
View File
@@ -1,20 +0,0 @@
export const FRIGATE_ICON_SVG_PATH =
'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' +
'-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' +
'-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' +
'5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' +
'1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' +
'-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' +
'-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' +
'-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' +
'-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' +
'-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' +
'0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' +
'0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' +
'0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' +
'0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' +
'-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' +
'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' +
'-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' +
'-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' +
'-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z';
-18
View File
@@ -1,18 +0,0 @@
import { ViewMedia } from '../../view/media';
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
export class FrigateViewMediaClassifier {
public static isFrigateMedia(
media: ViewMedia,
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
}
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
return media instanceof FrigateEventViewMedia;
}
public static isFrigateRecording(
media: ViewMedia,
): media is FrigateRecordingViewMedia {
return media instanceof FrigateRecordingViewMedia;
}
}
-181
View File
@@ -1,181 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import fromUnixTime from 'date-fns/fromUnixTime';
import isEqual from 'lodash-es/isEqual';
import { CameraConfig } from '../../types';
import {
ViewMedia,
EventViewMedia,
RecordingViewMedia,
ViewMediaType,
} from '../../view/media';
import { FrigateEvent, FrigateRecording } from './types';
import {
getEventMediaContentID,
getEventThumbnailURL,
getEventTitle,
getRecordingID,
getRecordingMediaContentID,
getRecordingTitle,
} from './util';
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
protected _event: FrigateEvent;
protected _contentID: string;
protected _thumbnail: string;
constructor(
mediaType: ViewMediaType,
cameraID: string,
event: FrigateEvent,
contentID: string,
thumbnail: string,
) {
super(mediaType, cameraID);
this._event = event;
this._contentID = contentID;
this._thumbnail = thumbnail;
}
public hasClip(): boolean {
return !!this._event.has_clip;
}
public getStartTime(): Date {
return fromUnixTime(this._event.start_time);
}
public getEndTime(): Date | null {
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
}
public getID(): string {
return this._event.id;
}
public getContentID(): string {
return this._contentID;
}
public getTitle(): string | null {
return getEventTitle(this._event);
}
public getThumbnail(): string | null {
return this._thumbnail;
}
public isFavorite(): boolean | null {
return this._event.retain_indefinitely ?? null;
}
public setFavorite(favorite: boolean): void {
this._event.retain_indefinitely = favorite;
}
public getWhat(): string[] | null {
return [this._event.label];
}
public getWhere(): string[] | null {
const zones = this._event.zones;
return zones.length ? zones : null;
}
public getScore(): number | null {
return this._event.top_score;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public isGroupableWith(that: EventViewMedia): boolean {
return (
this.getMediaType() === that.getMediaType() &&
isEqual(this.getWhere(), that.getWhere()) &&
isEqual(this.getWhat(), that.getWhat())
);
}
}
export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia {
protected _recording: FrigateRecording;
protected _id: string;
protected _contentID: string;
protected _title: string;
constructor(
mediaType: ViewMediaType,
cameraID: string,
recording: FrigateRecording,
id: string,
contentID: string,
title: string,
) {
super(mediaType, cameraID);
this._recording = recording;
this._id = id;
this._contentID = contentID;
this._title = title;
}
public getID(): string {
return this._id;
}
public getStartTime(): Date {
return this._recording.startTime;
}
public getEndTime(): Date {
return this._recording.endTime;
}
public getContentID(): string | null {
return this._contentID;
}
public getTitle(): string | null {
return this._title;
}
public getEventCount(): number {
return this._recording.events;
}
}
export class FrigateViewMediaFactory {
static createEventViewMedia(
mediaType: 'clip' | 'snapshot',
cameraID: string,
cameraConfig: CameraConfig,
event: FrigateEvent,
): FrigateEventViewMedia | null {
if (
(mediaType === 'clip' && !event.has_clip) ||
(mediaType === 'snapshot' && !event.has_snapshot) ||
!cameraConfig.frigate.client_id ||
!cameraConfig.frigate.camera_name
) {
return null;
}
return new FrigateEventViewMedia(
mediaType,
cameraID,
event,
getEventMediaContentID(
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
event,
mediaType === 'clip' ? 'clips' : 'snapshots',
),
getEventThumbnailURL(cameraConfig.frigate.client_id, event),
);
}
static createRecordingViewMedia(
cameraID: string,
recording: FrigateRecording,
cameraConfig: CameraConfig,
cameraTitle: string,
): FrigateRecordingViewMedia | null {
if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) {
return null;
}
return new FrigateRecordingViewMedia(
'recording',
cameraID,
recording,
getRecordingID(cameraConfig, recording),
getRecordingMediaContentID(
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
recording,
),
getRecordingTitle(cameraTitle, recording),
);
}
}
-151
View File
@@ -1,151 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import { localize } from '../../localize/localize';
import { FrigateCardError, RecordingSegment } from '../../types';
import { homeAssistantWSRequest } from '../../utils/ha';
import {
EventSummary,
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
recordingSegmentsSchema,
RecordingSummary,
recordingSummarySchema,
RetainResult,
retainResultSchema,
} from './types';
/**
* Get the recordings summary. May throw.
* @param hass The Home Assistant object.
* @param clientID The Frigate clientID.
* @param camera_name The Frigate camera name.
* @returns A RecordingSummary object.
*/
export const getRecordingsSummary = async (
hass: HomeAssistant,
clientID: string,
camera_name: string,
): Promise<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,
);
};
-68
View File
@@ -1,68 +0,0 @@
import { z } from 'zod';
import { dayToDate } from '../../utils/basic';
const dayStringToDate = (arg: unknown): Date | unknown => {
return typeof arg === 'string' ? dayToDate(arg) : arg;
};
export const eventSchema = z.object({
camera: z.string(),
end_time: z.number().nullable(),
false_positive: z.boolean().nullable(),
has_clip: z.boolean(),
has_snapshot: z.boolean(),
id: z.string(),
label: z.string(),
start_time: z.number(),
top_score: z.number(),
zones: z.string().array(),
retain_indefinitely: z.boolean().optional(),
});
export const frigateEventsSchema = eventSchema.array();
export type FrigateEvent = z.infer<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>;
-97
View File
@@ -1,97 +0,0 @@
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
import { CameraConfig, ClipsOrSnapshots } from '../../types';
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
import { FrigateEvent, FrigateRecording } from './types';
/**
* Given an event generate a title.
* @param event
*/
export const getEventTitle = (event: FrigateEvent): string => {
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const durationSeconds = Math.round(
event.end_time
? event.end_time - event.start_time
: Date.now() / 1000 - event.start_time,
);
return `${formatDateAndTime(
utcToZonedTime(event.start_time * 1000, localTimezone),
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
event.top_score * 100,
)}%]`;
};
export const getRecordingTitle = (
cameraTitle: string,
recording: FrigateRecording,
): string => {
return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`;
};
/**
* Get a thumbnail URL for an event.
* @param clientId The Frigate client id.
* @param event The event.
* @returns A string URL.
*/
export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => {
return `/api/frigate/${clientId}/thumbnail/${event.id}`;
};
/**
* Get a media content ID for an event.
* @param clientId The Frigate client id.
* @param cameraName The Frigate camera name.
* @param event The Frigate event.
* @param mediaType The media type required.
* @returns A string media content id.
*/
export const getEventMediaContentID = (
clientId: string,
cameraName: string,
event: FrigateEvent,
mediaType: ClipsOrSnapshots,
): string => {
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
};
/**
* Generate a recording identifier.
* @param clientId The Frigate client id.
* @param cameraName The Frigate camera name.
* @param recording The Frigate recording.
* @returns A recording identifier.
*/
export const getRecordingMediaContentID = (
clientId: string,
cameraName: string,
recording: FrigateRecording,
): string => {
return [
'media-source://frigate',
clientId,
'recordings',
cameraName,
`${recording.startTime.getFullYear()}-${String(
recording.startTime.getMonth() + 1,
).padStart(2, '0')}-${String(
String(recording.startTime.getDate()).padStart(2, '0'),
)}`,
String(recording.startTime.getHours()).padStart(2, '0'),
].join('/');
};
/**
* Get a recording ID for internal de-duping.
*/
export const getRecordingID = (
cameraConfig: CameraConfig,
recording: FrigateRecording,
): string => {
// ID name is derived from the real camera name (not CameraID) since the
// recordings for the same camera across multiple zones will be the same and
// can be dedup'd from this id.
return `${cameraConfig.frigate?.client_id ?? ''}/${
cameraConfig.frigate.camera_name ?? ''
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`;
};
-554
View File
@@ -1,554 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, CardWideConfig } from '../types.js';
import { allPromises, arrayify, setify } from '../utils/basic.js';
import {
CameraManagerCameraMetadata,
CameraManagerCapabilities,
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
EventQueryResults,
EventQueryResultsMap,
MediaMetadata,
MediaQuery,
PartialDataQuery,
PartialEventQuery,
PartialQueryConcreteType,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResults,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
} from './types.js';
import orderBy from 'lodash-es/orderBy';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js';
import { MediaQueriesResults } from '../view/media-queries-results';
import { MediaQueries } from '../view/media-queries.js';
import uniqBy from 'lodash-es/uniqBy';
import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum';
import add from 'date-fns/add';
import { log } from '../utils/debug.js';
export class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
return query.type === QueryType.Event;
}
public static isRecordingQuery(
query: DataQuery | PartialDataQuery,
): query is RecordingQuery {
return query.type === QueryType.Recording;
}
public static isRecordingSegmentsQuery(
query: DataQuery | PartialDataQuery,
): query is RecordingSegmentsQuery {
return query.type === QueryType.RecordingSegments;
}
}
export class QueryResultClassifier {
public static isEventQueryResult(
queryResults: QueryResults,
): queryResults is EventQueryResults {
return queryResults.type === QueryResultsType.Event;
}
public static isRecordingQuery(
queryResults: QueryResults,
): queryResults is RecordingQueryResults {
return queryResults.type === QueryResultsType.Recording;
}
public static isRecordingSegmentsQuery(
queryResults: QueryResults,
): queryResults is RecordingSegmentsQueryResults {
return queryResults.type === QueryResultsType.RecordingSegments;
}
}
export interface ExtendedMediaQueryResult<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);
}
}
-125
View File
@@ -1,125 +0,0 @@
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;
};
-169
View File
@@ -1,169 +0,0 @@
import { RecordingSegment } from '../types';
import { FrigateEvent, FrigateRecording } from './frigate/types';
// ====
// Base
// ====
export enum QueryType {
Event = 'event-query',
Recording = 'recording-query',
RecordingSegments = 'recording-segments-query',
}
export enum QueryResultsType {
Event = 'event-results',
Recording = 'recording-results',
RecordingSegments = 'recording-segments-results',
}
export enum Engine {
Frigate = 'frigate',
}
export interface DataQuery {
type: QueryType;
cameraIDs: Set<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;
}
-39
View File
@@ -1,39 +0,0 @@
import startOfHour from 'date-fns/startOfHour';
import endOfHour from 'date-fns/endOfHour';
import startOfDay from 'date-fns/startOfDay';
import endOfDay from 'date-fns/endOfDay';
import endOfMinute from 'date-fns/endOfMinute';
import { DateRange } from './range';
export const convertRangeToCacheFriendlyTimes = (
range: DateRange,
options?: {
endCap?: boolean;
},
): DateRange => {
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
let cacheableStart: Date;
let cacheableEnd: Date;
if (widthSeconds <= 60 * 60) {
cacheableStart = startOfHour(range.start);
cacheableEnd = endOfHour(range.end);
} else {
cacheableStart = startOfDay(range.start);
cacheableEnd = endOfDay(range.end);
}
if (options?.endCap) {
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
}
return {
start: cacheableStart,
end: cacheableEnd,
};
};
export const capEndDate = (end: Date): Date => {
const now = new Date();
return end > now ? now : end;
};
+2 -2
View File
@@ -91,9 +91,9 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { View } from './view/view.js';
import pkg from '../package.json';
import { ViewContext } from 'view';
import { CameraManager } from './camera/manager.js';
import { CameraManager } from './camera-manager/manager.js';
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
import { CameraManagerEngineFactory } from './camera/engine-factory.js';
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
import { log } from './utils/debug.js';
/** A note on media callbacks:
+2 -2
View File
@@ -22,7 +22,7 @@ import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view.js';
import { CameraManager, ExtendedMediaQueryResult } from '../camera/manager.js';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { View } from '../view/view.js';
import { renderProgressIndicator } from './message.js';
import './thumbnail.js';
@@ -30,7 +30,7 @@ import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import { createRef, Ref } from 'lit/directives/ref.js';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
import { MediaQueriesResults } from '../view/media-queries-results';
import { errorToConsole } from '../utils/basic';
import './media-filter';
+1 -1
View File
@@ -50,7 +50,7 @@ import '../surround.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import { classMap } from 'lit/directives/class-map.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { CameraManager } from '../../camera/manager.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { HomeAssistant } from 'custom-card-helpers';
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
import { HassEntity } from 'home-assistant-js-websocket';
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { DateRange } from '../camera/range';
import { DateRange } from '../camera-manager/range';
import { localize } from '../localize/localize';
import mediaFilterCoreStyle from '../scss/media-filter-core.scss';
import { ExtendedHomeAssistant } from '../types';
+3 -3
View File
@@ -16,8 +16,8 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera/manager';
import { DateRange } from '../camera/range';
import { CameraManager } from '../camera-manager/manager';
import { DateRange } from '../camera-manager/range';
import { CameraConfig, ExtendedHomeAssistant } from '../types';
import { View } from '../view/view';
import {
@@ -31,7 +31,7 @@ import {
} from './media-filter-core';
import './surround.js';
import './timeline-core.js';
import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera/types';
import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera-manager/types';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js';
import { HomeAssistant } from 'custom-card-helpers';
+1 -1
View File
@@ -27,7 +27,7 @@ import {
frigateCardHasAction,
getActionConfigGivenAction
} from '../utils/action.js';
import { FRIGATE_ICON_SVG_PATH } from '../camera/frigate/icon.js';
import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
import { refreshDynamicStateParameters } from '../utils/ha';
import './submenu.js';
+1 -1
View File
@@ -16,7 +16,7 @@ import {
ThumbnailsControlConfig,
} from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { CameraManager } from '../camera/manager.js';
import { CameraManager } from '../camera-manager/manager.js';
import { View } from '../view/view.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
import './surround-basic.js';
+1 -1
View File
@@ -25,7 +25,7 @@ import { FrigateCardCarousel } from './carousel.js';
import './thumbnail.js';
import './carousel.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../camera/manager.js';
import { CameraManager } from '../camera-manager/manager.js';
export interface ThumbnailCarouselTap {
queryResults: MediaQueriesResults;
+1 -1
View File
@@ -23,7 +23,7 @@ import { Task, TaskStatus } from '@lit-labs/task';
import type { CameraConfig, ExtendedHomeAssistant } from '../types.js';
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
import { CameraManager } from '../camera/manager.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ViewMediaClassifier } from '../view/media-classifier.js';
// The minimum width of a thumbnail with details enabled.
+2 -2
View File
@@ -49,7 +49,7 @@ import {
createViewForRecordings,
findClosestMediaIndex,
} from '../utils/media-to-view';
import { CameraManager } from '../camera/manager';
import { CameraManager } from '../camera-manager/manager';
import { EventMediaQueries, MediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { dispatchMessageEvent } from './message.js';
@@ -57,7 +57,7 @@ import './thumbnail.js';
import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import { rangesOverlap } from '../camera/range';
import { rangesOverlap } from '../camera-manager/range';
import { View } from '../view/view';
interface FrigateCardGroupData {
+1 -1
View File
@@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import timelineStyle from '../scss/timeline.scss';
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
import { CameraManager } from '../camera/manager';
import { CameraManager } from '../camera-manager/manager';
import { View } from '../view/view';
import './surround.js';
import './timeline-core.js';
+1 -1
View File
@@ -47,7 +47,7 @@ import '../patches/ha-hls-player';
import './surround.js';
import { renderTask } from '../utils/task.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { CameraManager } from '../camera/manager.js';
import { CameraManager } from '../camera-manager/manager.js';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
+1 -1
View File
@@ -142,7 +142,7 @@ import {
} from './types.js';
import { arrayMove, prettifyTitle } from './utils/basic.js';
import { getCameraID } from './utils/camera.js';
import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/icon.js';
import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
import {
getEntitiesFromHASS,
getEntityTitle,
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from 'custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map.js';
import { z } from 'zod';
import { eventSchema, FrigateEvent, FrigateRecording } from './camera/frigate/types.js';
import { eventSchema, FrigateEvent, FrigateRecording } from './camera-manager/frigate/types.js';
import { deepRemoveDefaults } from './utils/zod.js';
// The min allowed size of buttons.
+1 -1
View File
@@ -1,6 +1,6 @@
import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from '.';
import { FrigateEvent, FrigateRecording } from '../../camera/frigate/types';
import { FrigateEvent, FrigateRecording } from '../../camera-manager/frigate/types';
import { dispatchErrorMessageEvent } from '../../components/message.js';
import { localize } from '../../localize/localize.js';
import {
+1 -1
View File
@@ -8,7 +8,7 @@ import {
MediaQueries,
RecordingMediaQueries,
} from '../view/media-queries';
import { CameraManager } from '../camera/manager';
import { CameraManager } from '../camera-manager/manager';
import { getAllDependentCameras } from './camera.js';
import { ViewMedia } from '../view/media';
import { HomeAssistant } from 'custom-card-helpers';
+4 -4
View File
@@ -4,12 +4,12 @@ import sub from 'date-fns/sub';
import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types';
import { CameraManager } from '../camera/manager';
import { EventQuery } from '../camera/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util';
import { CameraManager } from '../camera-manager/manager';
import { EventQuery } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { EventMediaQueries } from '../view/media-queries';
import { ViewMedia } from '../view/media';
import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range';
import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera-manager/range';
import { errorToConsole, ModifyInterface } from './basic.js';
// Allow timeline freshness to be at least this number of seconds out of date
+1 -1
View File
@@ -1,6 +1,6 @@
import isEqual from 'lodash-es/isEqual';
import cloneDeep from 'lodash-es/cloneDeep.js';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types.js';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js';
export type MediaQueries = EventMediaQueries | RecordingMediaQueries;