From 67b134308a7e9814e157bf0a6c83077e3b7eb030 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 26 Jan 2023 21:55:07 -0800 Subject: [PATCH] Fetch metadata from engine. --- src/camera/engine-factory.ts | 9 ++- src/camera/engine.ts | 10 ++- src/camera/frigate/engine-frigate.ts | 63 +++++++++++++++-- src/camera/frigate/requests.ts | 45 +++++++++--- src/camera/frigate/types.ts | 23 ++++-- src/camera/manager.ts | 42 ++++++++++- src/camera/types.ts | 6 ++ src/components/media-filter.ts | 101 +++++++++++++++++++++------ src/utils/basic.ts | 21 +++++- src/view/view.ts | 1 + 10 files changed, 270 insertions(+), 51 deletions(-) diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index 1a3bdbb6..889ff185 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -62,6 +62,13 @@ export class CameraManagerEngineFactory { } output.get(engine)?.add(cameraID); } - return output; + return output.size ? output : null; + } + + public getAllEngines( + cameras: Map, + ): CameraManagerEngine[] | null { + const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys())); + return engines ? [...engines.keys()] : null; } } diff --git a/src/camera/engine.ts b/src/camera/engine.ts index 904c9bdf..cbf67284 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -5,6 +5,7 @@ import { DataQuery, EventQuery, EventQueryResultsMap, + MediaMetadata, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, @@ -75,9 +76,7 @@ export interface CameraManagerEngine { favorite: boolean, ): Promise; - getQueryResultMaxAge( - query: DataQuery - ): number | null; + getQueryResultMaxAge(query: DataQuery): number | null; getMediaSeekTime( hass: HomeAssistant, @@ -85,4 +84,9 @@ export interface CameraManagerEngine { media: ViewMedia, target: Date, ): Promise; + + getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise; } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 0d0aa9f3..2220a01c 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -20,6 +20,7 @@ import { FrigateEventQueryResults, FrigateRecordingQueryResults, FrigateRecordingSegmentsQueryResults, + MediaMetadata, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, @@ -36,6 +37,7 @@ import { import { FrigateRecording } from './types'; import { getEvents, + getEventSummary, getRecordingSegments, getRecordingsSummary, NativeFrigateEventQuery, @@ -44,7 +46,7 @@ import { } from './requests'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; -import { runWhenIdleIfSupported } from '../../utils/basic'; +import { allPromises, runWhenIdleIfSupported } from '../../utils/basic'; import { fromUnixTime } from 'date-fns'; import { sum } from 'lodash-es'; import { FrigateViewMediaClassifier } from './media-classifier'; @@ -214,10 +216,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { protected _buildInstanceToCameraIDMapFromQuery( cameras: Map, - query: DataQuery, + cameraIDs: Set, ): Map> { const output: Map> = new Map(); - for (const cameraID of query.cameraIDs) { + for (const cameraID of cameraIDs) { const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); const clientID = cameraConfig?.frigate.client_id; if (clientID) { @@ -295,7 +297,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { // 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); + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + query.cameraIDs, + ); await Promise.all( Array.from(instances.keys()).map((instanceID) => @@ -611,6 +616,56 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return cameraConfig; } + public async getMediaMetadata( + hass: HomeAssistant, + cameras: Map, + ): Promise { + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + new Set(cameras.keys()), + ); + + const processQuery = async ( + instanceID: string, + cameraIDs: Set, + ): Promise => { + const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs); + for (const entry of await getEventSummary(hass, instanceID)) { + if (!cameraNames.has(entry.camera)) { + // If this entry applies to a camera that *is* in this Frigate + // instance, but is *not* a configured camera in the card, skip it. + continue; + } + if (entry.label) { + what.add(entry.label); + } + if (entry.zones.length) { + entry.zones.forEach(where.add, where); + } + if (entry.day) { + days.add(entry.day); + } + } + }; + + 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 * returned by the Frigate backend. diff --git a/src/camera/frigate/requests.ts b/src/camera/frigate/requests.ts index dcb75ec4..01d48878 100644 --- a/src/camera/frigate/requests.ts +++ b/src/camera/frigate/requests.ts @@ -1,22 +1,29 @@ import { HomeAssistant } from 'custom-card-helpers'; import { localize } from '../../localize/localize'; -import { - FrigateCardError, - RecordingSegment, -} from '../../types'; +import { FrigateCardError, RecordingSegment } from '../../types'; 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. * @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. * @returns A RecordingSummary object. */ export const getRecordingsSummary = async ( hass: HomeAssistant, - client_id: string, + clientID: string, camera_name: string, ): Promise => { return await homeAssistantWSRequest( @@ -24,7 +31,7 @@ export const getRecordingsSummary = async ( recordingSummarySchema, { type: 'frigate/recordings/summary', - instance_id: client_id, + instance_id: clientID, camera: camera_name, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, @@ -63,19 +70,19 @@ export const getRecordingSegments = async ( /** * Request that Frigate retain an event. May throw. * @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 retain `true` to retain or `false` to unretain. */ export async function retainEvent( hass: HomeAssistant, - client_id: string, + clientID: string, eventID: string, retain: boolean, ): Promise { const retainRequest = { type: 'frigate/event/retain', - instance_id: client_id, + instance_id: clientID, event_id: eventID, retain: retain, }; @@ -126,3 +133,19 @@ export const getEvents = async ( true, ); }; + +export const getEventSummary = async ( + hass: HomeAssistant, + clientID: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + eventSummarySchema, + { + type: 'frigate/events/summary', + instance_id: clientID, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + ); +}; diff --git a/src/camera/frigate/types.ts b/src/camera/frigate/types.ts index 81135e91..41a0cd6b 100644 --- a/src/camera/frigate/types.ts +++ b/src/camera/frigate/types.ts @@ -1,4 +1,9 @@ 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(), @@ -25,11 +30,7 @@ const recordingSummaryHourSchema = z.object({ export const recordingSummarySchema = z .object({ - day: z.preprocess((arg) => { - // 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()), + day: z.preprocess(dayStringToDate, z.date()), events: z.number(), hours: recordingSummaryHourSchema.array(), }) @@ -54,4 +55,14 @@ export interface FrigateRecording { startTime: Date; endTime: Date; events: number; -} \ No newline at end of file +} + +export const eventSummarySchema = z + .object({ + camera: z.string(), + day: z.string(), + label: z.string(), + zones: z.string().array(), + }) + .array(); +export type EventSummary = z.infer; diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 17142044..89597178 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -1,11 +1,12 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig } from '../types.js'; -import { arrayify, setify } from '../utils/basic.js'; +import { allPromises, arrayify, setify } from '../utils/basic.js'; import { DataQuery, EventQuery, EventQueryResults, EventQueryResultsMap, + MediaMetadata, MediaQuery, PartialDataQuery, PartialEventQuery, @@ -115,6 +116,45 @@ export class CameraManager { }); } + public async getMediaMetadata( + hass: HomeAssistant, + ): Promise { + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const engines = this._engineFactory.getAllEngines(this._cameras); + if (!engines) { + return null; + } + + const processMetadata = async (engine: CameraManagerEngine): Promise => { + const engineMetadata = await engine.getMediaMetadata(hass, this._cameras); + if (engineMetadata) { + if (engineMetadata.what) { + engineMetadata.what.forEach(what.add, what); + } + if (engineMetadata.where) { + engineMetadata.where.forEach(where.add, where); + } + if (engineMetadata.days) { + engineMetadata.days.forEach(days.add, days); + } + } + } + + await allPromises(engines, (engine) => processMetadata(engine)); + + if (!what.size && !where.size && !days.size) { + return null; + } + return { + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + } + } + protected _generateDefaultQueries( cameraIDs: string | Set, partialQuery: PQT, diff --git a/src/camera/types.ts b/src/camera/types.ts index ebd61e80..6c5b1ed8 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -68,6 +68,12 @@ export type EventQueryResultsMap = ResultsMap; export type RecordingQueryResultsMap = ResultsMap; export type RecordingSegmentsQueryResultsMap = ResultsMap; +export interface MediaMetadata { + where?: Set; + what?: Set; + days?: Set; +} + // =========== // Event Query // =========== diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 9ff2c354..18cb55fa 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -11,6 +11,8 @@ import { html, LitElement, PropertyValues, + ReactiveController, + ReactiveControllerHost, TemplateResult, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -31,6 +33,11 @@ import './timeline-core.js'; import { EventQuery, QueryType } from '../camera/types'; import { EventMediaQueries } from '../view/media-queries'; 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') export class FrigateCardMediaFilter extends LitElement { @@ -50,6 +57,7 @@ export class FrigateCardMediaFilter extends LitElement { public mediaLimit?: number; protected _cameraOptions: ValueLabel[] = []; + protected _mediaMetadataController?: MediaMetadataController; protected _convertWhenToDateRange( 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 { - // 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` `; @@ -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[] = []; + public whatOptions: ValueLabel[] = []; + public whereOptions: ValueLabel[] = []; + + 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 = 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 { interface HTMLElementTagNameMap { 'frigate-card-media-filter': FrigateCardMediaFilter; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 040781bd..19ce2cb6 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -32,6 +32,7 @@ export function dispatchFrigateCardEvent( * @param input The input Frigate (camera/label/zone) name. * @returns A prettified name. */ +export function prettifyTitle(input: string): string; export function prettifyTitle(input?: string): string | undefined { if (!input) { return undefined; @@ -158,8 +159,24 @@ export function getDurationString(start: Date, end: Date): string { /** * For debug purposes only. - * @param seconds + * @param seconds */ 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 ( + items: T[], + func: (arg: T) => void, +): Promise => { + 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`); } \ No newline at end of file diff --git a/src/view/view.ts b/src/view/view.ts index 07cb9d20..5386fcf3 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -2,6 +2,7 @@ // - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript // - TODO: getRecordingTitle should use getCameraTitle but need hass. // - 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: // - TODO: Event gallery show_details default does not work.