Fetch metadata from engine.

This commit is contained in:
Dermot Duffy
2023-01-26 21:55:07 -08:00
parent bd19ed7a74
commit 67b134308a
10 changed files with 270 additions and 51 deletions
+8 -1
View File
@@ -62,6 +62,13 @@ export class CameraManagerEngineFactory {
} }
output.get(engine)?.add(cameraID); output.get(engine)?.add(cameraID);
} }
return output; return output.size ? output : null;
}
public getAllEngines(
cameras: Map<string, CameraConfig>,
): CameraManagerEngine[] | null {
const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys()));
return engines ? [...engines.keys()] : null;
} }
} }
+7 -3
View File
@@ -5,6 +5,7 @@ import {
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResultsMap, EventQueryResultsMap,
MediaMetadata,
PartialEventQuery, PartialEventQuery,
PartialRecordingQuery, PartialRecordingQuery,
PartialRecordingSegmentsQuery, PartialRecordingSegmentsQuery,
@@ -75,9 +76,7 @@ export interface CameraManagerEngine {
favorite: boolean, favorite: boolean,
): Promise<void>; ): Promise<void>;
getQueryResultMaxAge( getQueryResultMaxAge(query: DataQuery): number | null;
query: DataQuery
): number | null;
getMediaSeekTime( getMediaSeekTime(
hass: HomeAssistant, hass: HomeAssistant,
@@ -85,4 +84,9 @@ export interface CameraManagerEngine {
media: ViewMedia, media: ViewMedia,
target: Date, target: Date,
): Promise<number | null>; ): Promise<number | null>;
getMediaMetadata(
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
): Promise<MediaMetadata | null>;
} }
+59 -4
View File
@@ -20,6 +20,7 @@ import {
FrigateEventQueryResults, FrigateEventQueryResults,
FrigateRecordingQueryResults, FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults, FrigateRecordingSegmentsQueryResults,
MediaMetadata,
PartialEventQuery, PartialEventQuery,
PartialRecordingQuery, PartialRecordingQuery,
PartialRecordingSegmentsQuery, PartialRecordingSegmentsQuery,
@@ -36,6 +37,7 @@ import {
import { FrigateRecording } from './types'; import { FrigateRecording } from './types';
import { import {
getEvents, getEvents,
getEventSummary,
getRecordingSegments, getRecordingSegments,
getRecordingsSummary, getRecordingsSummary,
NativeFrigateEventQuery, NativeFrigateEventQuery,
@@ -44,7 +46,7 @@ import {
} from './requests'; } from './requests';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle'; import throttle from 'lodash-es/throttle';
import { runWhenIdleIfSupported } from '../../utils/basic'; import { allPromises, runWhenIdleIfSupported } from '../../utils/basic';
import { fromUnixTime } from 'date-fns'; import { fromUnixTime } from 'date-fns';
import { sum } from 'lodash-es'; import { sum } from 'lodash-es';
import { FrigateViewMediaClassifier } from './media-classifier'; import { FrigateViewMediaClassifier } from './media-classifier';
@@ -214,10 +216,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
protected _buildInstanceToCameraIDMapFromQuery( protected _buildInstanceToCameraIDMapFromQuery(
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
query: DataQuery, cameraIDs: Set<string>,
): Map<string, Set<string>> { ): Map<string, Set<string>> {
const output: Map<string, Set<string>> = new Map(); const output: Map<string, Set<string>> = new Map();
for (const cameraID of query.cameraIDs) { for (const cameraID of cameraIDs) {
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
const clientID = cameraConfig?.frigate.client_id; const clientID = cameraConfig?.frigate.client_id;
if (clientID) { if (clientID) {
@@ -295,7 +297,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
// Frigate allows multiple cameras to be searched for events in a single // 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. Break them down into groups of cameras per Frigate instance, then
// query once per instance for all cameras in that instance. // query once per instance for all cameras in that instance.
const instances = this._buildInstanceToCameraIDMapFromQuery(cameras, query); const instances = this._buildInstanceToCameraIDMapFromQuery(
cameras,
query.cameraIDs,
);
await Promise.all( await Promise.all(
Array.from(instances.keys()).map((instanceID) => Array.from(instances.keys()).map((instanceID) =>
@@ -611,6 +616,56 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
return cameraConfig; 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 processQuery = 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);
}
}
};
await allPromises([...instances.entries()], ([instanceID, cameraIDs]) =>
processQuery(instanceID, 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 * Garbage collect recording segments that no longer feature in the recordings
* returned by the Frigate backend. * returned by the Frigate backend.
+34 -11
View File
@@ -1,22 +1,29 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { import { FrigateCardError, RecordingSegment } from '../../types';
FrigateCardError,
RecordingSegment,
} from '../../types';
import { homeAssistantWSRequest } from '../../utils/ha'; import { homeAssistantWSRequest } from '../../utils/ha';
import { FrigateEvent, frigateEventsSchema, recordingSegmentsSchema, RecordingSummary, recordingSummarySchema, RetainResult, retainResultSchema } from './types'; import {
EventSummary,
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
recordingSegmentsSchema,
RecordingSummary,
recordingSummarySchema,
RetainResult,
retainResultSchema,
} from './types';
/** /**
* Get the recordings summary. May throw. * Get the recordings summary. May throw.
* @param hass The Home Assistant object. * @param hass The Home Assistant object.
* @param client_id The Frigate client_id. * @param clientID The Frigate clientID.
* @param camera_name The Frigate camera name. * @param camera_name The Frigate camera name.
* @returns A RecordingSummary object. * @returns A RecordingSummary object.
*/ */
export const getRecordingsSummary = async ( export const getRecordingsSummary = async (
hass: HomeAssistant, hass: HomeAssistant,
client_id: string, clientID: string,
camera_name: string, camera_name: string,
): Promise<RecordingSummary> => { ): Promise<RecordingSummary> => {
return await homeAssistantWSRequest( return await homeAssistantWSRequest(
@@ -24,7 +31,7 @@ export const getRecordingsSummary = async (
recordingSummarySchema, recordingSummarySchema,
{ {
type: 'frigate/recordings/summary', type: 'frigate/recordings/summary',
instance_id: client_id, instance_id: clientID,
camera: camera_name, camera: camera_name,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, },
@@ -63,19 +70,19 @@ export const getRecordingSegments = async (
/** /**
* Request that Frigate retain an event. May throw. * Request that Frigate retain an event. May throw.
* @param hass The HomeAssistant object. * @param hass The HomeAssistant object.
* @param client_id The Frigate client_id. * @param clientID The Frigate clientID.
* @param eventID The event ID to retain. * @param eventID The event ID to retain.
* @param retain `true` to retain or `false` to unretain. * @param retain `true` to retain or `false` to unretain.
*/ */
export async function retainEvent( export async function retainEvent(
hass: HomeAssistant, hass: HomeAssistant,
client_id: string, clientID: string,
eventID: string, eventID: string,
retain: boolean, retain: boolean,
): Promise<void> { ): Promise<void> {
const retainRequest = { const retainRequest = {
type: 'frigate/event/retain', type: 'frigate/event/retain',
instance_id: client_id, instance_id: clientID,
event_id: eventID, event_id: eventID,
retain: retain, retain: retain,
}; };
@@ -126,3 +133,19 @@ export const getEvents = async (
true, 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,
);
};
+16 -5
View File
@@ -1,4 +1,9 @@
import { z } from 'zod'; 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({ export const eventSchema = z.object({
camera: z.string(), camera: z.string(),
@@ -25,11 +30,7 @@ const recordingSummaryHourSchema = z.object({
export const recordingSummarySchema = z export const recordingSummarySchema = z
.object({ .object({
day: z.preprocess((arg) => { day: z.preprocess(dayStringToDate, z.date()),
// Must provide the hour:minute:second on parsing or Javascript will
// assume *UTC* midnight.
return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg;
}, z.date()),
events: z.number(), events: z.number(),
hours: recordingSummaryHourSchema.array(), hours: recordingSummaryHourSchema.array(),
}) })
@@ -55,3 +56,13 @@ export interface FrigateRecording {
endTime: Date; endTime: Date;
events: number; 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>;
+41 -1
View File
@@ -1,11 +1,12 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types.js'; import { CameraConfig } from '../types.js';
import { arrayify, setify } from '../utils/basic.js'; import { allPromises, arrayify, setify } from '../utils/basic.js';
import { import {
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
EventQueryResultsMap, EventQueryResultsMap,
MediaMetadata,
MediaQuery, MediaQuery,
PartialDataQuery, PartialDataQuery,
PartialEventQuery, PartialEventQuery,
@@ -115,6 +116,45 @@ export class CameraManager {
}); });
} }
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>( protected _generateDefaultQueries<PQT extends PartialDataQuery>(
cameraIDs: string | Set<string>, cameraIDs: string | Set<string>,
partialQuery: PQT, partialQuery: PQT,
+6
View File
@@ -68,6 +68,12 @@ export type EventQueryResultsMap = ResultsMap<EventQuery>;
export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>; export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>;
export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>; export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>;
export interface MediaMetadata {
where?: Set<string>;
what?: Set<string>;
days?: Set<string>;
}
// =========== // ===========
// Event Query // Event Query
// =========== // ===========
+78 -23
View File
@@ -11,6 +11,8 @@ import {
html, html,
LitElement, LitElement,
PropertyValues, PropertyValues,
ReactiveController,
ReactiveControllerHost,
TemplateResult, TemplateResult,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
@@ -31,6 +33,11 @@ import './timeline-core.js';
import { EventQuery, QueryType } from '../camera/types'; import { EventQuery, QueryType } from '../camera/types';
import { EventMediaQueries } from '../view/media-queries'; import { EventMediaQueries } from '../view/media-queries';
import { createViewForEvents } from '../utils/media-to-view.js'; import { createViewForEvents } from '../utils/media-to-view.js';
import { HomeAssistant } from 'custom-card-helpers';
import { prettifyTitle } from '../utils/basic';
import format from 'date-fns/format';
import parse from 'date-fns/parse';
import endOfMonth from 'date-fns/endOfMonth';
@customElement('frigate-card-media-filter') @customElement('frigate-card-media-filter')
export class FrigateCardMediaFilter extends LitElement { export class FrigateCardMediaFilter extends LitElement {
@@ -50,6 +57,7 @@ export class FrigateCardMediaFilter extends LitElement {
public mediaLimit?: number; public mediaLimit?: number;
protected _cameraOptions: ValueLabel<string>[] = []; protected _cameraOptions: ValueLabel<string>[] = [];
protected _mediaMetadataController?: MediaMetadataController;
protected _convertWhenToDateRange( protected _convertWhenToDateRange(
value?: MediaFilterCoreWhenSelection, value?: MediaFilterCoreWhenSelection,
@@ -127,35 +135,23 @@ export class FrigateCardMediaFilter extends LitElement {
}), }),
); );
} }
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
this._mediaMetadataController = new MediaMetadataController(
this,
this.hass,
this.cameraManager,
);
}
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
// TODO Replace with real custom when options
// TODO Replace with real custom what options
// TODO Replace with real custom where options
const whereOptions = [{ value: 'steps', label: 'Front Steps' }];
const whatOptions = [
{ value: 'car', label: 'Car' },
{ value: 'person', label: 'Person' },
];
const whenOptions = [
{
value: {
selection: MediaFilterCoreWhen.Custom,
custom: { start: startOfToday(), end: endOfToday() },
},
label: 'December 2021',
},
];
return html` <frigate-card-media-filter-core return html` <frigate-card-media-filter-core
.hass=${this.hass} .hass=${this.hass}
.whenOptions=${whenOptions} .whenOptions=${this._mediaMetadataController?.whenOptions}
.cameraOptions=${this._cameraOptions} .cameraOptions=${this._cameraOptions}
.whatOptions=${whatOptions} .whatOptions=${this._mediaMetadataController?.whatOptions}
.whereOptions=${whereOptions} .whereOptions=${this._mediaMetadataController?.whereOptions}
@frigate-card:media-filter-core:change=${this._mediaFilterHandler.bind(this)} @frigate-card:media-filter-core:change=${this._mediaFilterHandler.bind(this)}
> >
</frigate-card-media-filter-core>`; </frigate-card-media-filter-core>`;
@@ -170,6 +166,65 @@ export class FrigateCardMediaFilter extends LitElement {
} }
} }
export class MediaMetadataController implements ReactiveController {
protected _host: ReactiveControllerHost;
protected _hass: HomeAssistant;
protected _cameraManager: CameraManager;
public whenOptions: ValueLabel<MediaFilterCoreWhenSelection>[] = [];
public whatOptions: ValueLabel<string>[] = [];
public whereOptions: ValueLabel<string>[] = [];
constructor(
host: ReactiveControllerHost,
hass: HomeAssistant,
cameraManager: CameraManager,
) {
this._host = host;
this._hass = hass;
this._cameraManager = cameraManager;
host.addController(this);
}
async hostConnected() {
const metadata = await this._cameraManager.getMediaMetadata(this._hass);
if (metadata) {
if (metadata.what) {
this.whatOptions = [...metadata.what]
.sort()
.map((what) => ({ value: what, label: prettifyTitle(what) }));
}
if (metadata.where) {
this.whereOptions = [...metadata.where]
.sort()
.map((where) => ({ value: where, label: prettifyTitle(where) }));
}
if (metadata.days) {
const yearMonths: Set<string> = new Set();
[...metadata.days].forEach((day) => {
// An efficient conversion: 2023-01-26 -> 2023-01
yearMonths.add(day.substring(0, 7));
});
const monthStarts: Date[] = [];
yearMonths.forEach((yearMonth) => {
monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date()));
});
this.whenOptions = monthStarts
.sort()
.reverse()
.map((monthStart) => ({
label: format(monthStart, 'MMMM yyyy'),
value: {
selection: MediaFilterCoreWhen.Custom,
custom: { start: monthStart, end: endOfMonth(monthStart) },
},
}));
}
this._host.requestUpdate();
}
}
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'frigate-card-media-filter': FrigateCardMediaFilter; 'frigate-card-media-filter': FrigateCardMediaFilter;
+18 -1
View File
@@ -32,6 +32,7 @@ export function dispatchFrigateCardEvent<T>(
* @param input The input Frigate (camera/label/zone) name. * @param input The input Frigate (camera/label/zone) name.
* @returns A prettified name. * @returns A prettified name.
*/ */
export function prettifyTitle(input: string): string;
export function prettifyTitle(input?: string): string | undefined { export function prettifyTitle(input?: string): string | undefined {
if (!input) { if (!input) {
return undefined; return undefined;
@@ -161,5 +162,21 @@ export function getDurationString(start: Date, end: Date): string {
* @param seconds * @param seconds
*/ */
export const sleep = async (seconds: number) => { export const sleep = async (seconds: number) => {
await new Promise(r => setTimeout(r, seconds * 1000)); await new Promise((r) => setTimeout(r, seconds * 1000));
};
export const allPromises = async <T>(
items: T[],
func: (arg: T) => void,
): Promise<void> => {
await Promise.all(Array.from(items).map((item) => func(item)));
};
/**
* Simple efficient YYYY-MM-DD -> date converter.
*/
export const dayToDate = (day: string): Date => {
// Must provide the hour:minute:second on parsing or Javascript will assume
// *UTC* midnight.
return new Date(`${day}T00:00:00`);
} }
+1
View File
@@ -2,6 +2,7 @@
// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript
// - TODO: getRecordingTitle should use getCameraTitle but need hass. // - TODO: getRecordingTitle should use getCameraTitle but need hass.
// - TODO: Take MediaQueries wrappers out of the camera manager. // - TODO: Take MediaQueries wrappers out of the camera manager.
// - TODO: View a media in the gallery from September, then notice timeline missing the item.
// Gallery: // Gallery:
// - TODO: Event gallery show_details default does not work. // - TODO: Event gallery show_details default does not work.