Move frigate utils under the camera manager directory for Frigate.

This commit is contained in:
Dermot Duffy
2023-01-24 19:36:54 -08:00
parent 39b44863ce
commit d8614a3e8d
10 changed files with 27 additions and 26 deletions
+397
View File
@@ -0,0 +1,397 @@
import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour';
import getUnixTime from 'date-fns/getUnixTime';
import startOfHour from 'date-fns/startOfHour';
import { CAMERA_BIRDSEYE } from '../../const';
import { CameraConfig, FrigateRecording } from '../../types';
import { MediaQueries, MediaQueriesResults } from '../../view';
import { ViewMedia, ViewMediaClassifier, ViewMediaFactory } from '../../view-media';
import { errorToConsole } from '../../utils/basic';
import {
getEvents,
getRecordingSegments,
getRecordingsSummary,
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
RecordingSegments,
RecordingSummary,
retainEvent,
} from './frigate';
import { RecordingSegmentsCache } from '../cache';
import {
CameraManagerEngine,
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
} from '../engine';
import { CameraManagerError } from '../error';
import { DateRange } from '../range';
import {
Engine,
EventQuery,
FrigateEventQueryResults,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingSegmentsQuery,
} from '../types';
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;
constructor(recordingSegmentsCache: RecordingSegmentsCache) {
this._recordingSegmentsCache = recordingSegmentsCache;
}
public getMediaDownloadPath(
cameraConfig: CameraConfig,
media: ViewMedia,
): string | null {
let path: string | null = null;
if (ViewMediaClassifier.isFrigateEvent(media)) {
path =
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/notifications/${media.getID()}/` +
`${media.isClip() ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`;
} else if (ViewMediaClassifier.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(
cameraID: string,
cameraConfig: CameraConfig,
query: PartialEventQuery,
): EventQuery | null {
return {
type: QueryType.Event,
cameraID: cameraID,
...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }),
...(cameraConfig.frigate.zone && { zone: cameraConfig.frigate.zone }),
...query,
};
}
public generateDefaultRecordingQuery(
cameraID: string,
_cameraConfig: CameraConfig,
query: PartialRecordingQuery,
): RecordingQuery | null {
return {
type: QueryType.Recording,
cameraID: cameraID,
...query,
};
}
public generateDefaultRecordingSegmentsQuery(
cameraID: string,
_cameraConfig: CameraConfig,
query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery | null {
if (!query.start || !query.end) {
return null;
}
return {
type: QueryType.RecordingSegments,
cameraID: cameraID,
start: query.start,
end: query.end,
...query,
};
}
public async favoriteMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
favorite: boolean,
): Promise<void> {
const clientID = cameraConfig.frigate.client_id;
if (!ViewMediaClassifier.isFrigateEvent(media)) {
return;
}
try {
await retainEvent(hass, clientID, media.getID(cameraConfig), favorite);
} catch (e) {
errorToConsole(e as Error);
throw new CameraManagerError((e as Error).message);
}
media.setFavorite(favorite);
}
public async getEvents(
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
query: EventQuery,
): Promise<QueryReturnType<EventQuery> | null> {
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
if (!cameraConfig) {
return null;
}
const nativeQuery: NativeFrigateEventQuery = {
instance_id: cameraConfig.frigate.client_id,
camera: cameraConfig.frigate.camera_name,
...(query.what && { label: query.what }),
...(query.where && { zone: 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 }),
limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
};
try {
const result: FrigateEventQueryResults = {
type: QueryResultsType.Event,
engine: Engine.Frigate,
events: await getEvents(hass, nativeQuery),
expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }),
};
return result;
} catch (e) {
errorToConsole(e as Error);
throw new CameraManagerError((e as Error).message, query);
}
}
public async getRecordings(
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
query: RecordingQuery,
): Promise<QueryReturnType<RecordingQuery> | null> {
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
if (!cameraConfig) {
return null;
}
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
return null;
}
let recordingSummary: RecordingSummary;
try {
recordingSummary = await getRecordingsSummary(
hass,
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
);
} catch (e) {
errorToConsole(e as Error);
throw new CameraManagerError((e as Error).message, query);
}
const 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({
camera: cameraConfig.frigate.camera_name,
start_time: getUnixTime(startHour),
end_time: getUnixTime(endHour),
events: hourData.events,
});
}
}
}
return <FrigateRecordingQueryResults>{
type: QueryResultsType.Recording,
engine: Engine.Frigate,
recordings: recordings,
expiry: add(new Date(), {
seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS,
}),
};
}
public async getRecordingSegments(
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
query: RecordingSegmentsQuery,
): Promise<QueryReturnType<RecordingSegmentsQuery> | null> {
const cameraConfig = this._getQueryableCameraConfig(cameras, query.cameraID);
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
return null;
}
const range: DateRange = { start: query.start, end: query.end };
// A note on Frigate Recording Segments:
// - Unlike other query types, 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(query.cameraID, range);
if (cachedSegments) {
return {
type: QueryResultsType.RecordingSegments,
engine: Engine.Frigate,
segments: cachedSegments,
};
}
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),
};
let segments: RecordingSegments;
try {
segments = await getRecordingSegments(hass, request);
} catch (e) {
errorToConsole(e as Error);
throw new CameraManagerError((e as Error).message, query);
}
this._recordingSegmentsCache.add(query.cameraID, range, segments);
return {
type: QueryResultsType.RecordingSegments,
engine: Engine.Frigate,
segments: segments,
};
}
public generateMediaFromEvents(
query: EventQuery,
results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) {
return null;
}
const output: ViewMedia[] = [];
for (const event of results.events) {
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 = ViewMediaFactory.createViewMediaFromFrigateEvent(
mediaType,
query.cameraID,
event,
);
if (media) {
output.push(media);
}
}
return output;
}
public generateMediaFromRecordings(
query: RecordingQuery,
results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null {
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) {
return null;
}
const output: ViewMedia[] = [];
for (const recording of results.recordings) {
const media = ViewMediaFactory.createViewMediaFromFrigateRecording(
query.cameraID,
recording,
);
if (media) {
output.push(media);
}
}
return output;
}
public areMediaQueriesResultsFresh(
queries: MediaQueries,
results: MediaQueriesResults,
): boolean {
let freshThreshold: number | null = null;
if (queries.areEventQueries()) {
freshThreshold = EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (queries.areRecordingQueries()) {
freshThreshold = RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
}
const now = new Date();
const resultsTimestamp = results.getResultsTimestamp();
return (
!freshThreshold ||
!resultsTimestamp ||
add(resultsTimestamp, { seconds: freshThreshold }) >= now
);
}
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;
}
}