From 1ad600c2312a0afc9498593213e82cfc2260a10d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 29 Jan 2023 08:02:36 -0800 Subject: [PATCH] Improve media selection logic. --- src/camera/engine-factory.ts | 13 ++++ src/camera/engine.ts | 5 ++ src/camera/frigate/engine-frigate.ts | 15 ++++ src/camera/manager.ts | 22 ++++++ src/camera/types.ts | 13 ++++ src/components/media-filter-core.ts | 18 ++--- src/components/media-filter.ts | 112 ++++++++++++++++++++------- src/components/thumbnail.ts | 17 +++- src/utils/media-to-view.ts | 92 +++++++++++++++------- src/view/view.ts | 1 + 10 files changed, 243 insertions(+), 65 deletions(-) diff --git a/src/camera/engine-factory.ts b/src/camera/engine-factory.ts index 889ff185..d678d97b 100644 --- a/src/camera/engine-factory.ts +++ b/src/camera/engine-factory.ts @@ -1,4 +1,5 @@ import { CameraConfig } from '../types'; +import { ViewMedia } from '../view/media'; import { RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; @@ -65,6 +66,18 @@ export class CameraManagerEngineFactory { return output.size ? output : null; } + public getEngineForMedia( + cameras: Map, + 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, ): CameraManagerEngine[] | null { diff --git a/src/camera/engine.ts b/src/camera/engine.ts index cbf67284..646821a4 100644 --- a/src/camera/engine.ts +++ b/src/camera/engine.ts @@ -14,6 +14,8 @@ import { RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -89,4 +91,7 @@ export interface CameraManagerEngine { hass: HomeAssistant, cameras: Map, ): Promise; + + getCapabilities(): CameraManagerEngineCapabilities | null; + getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; } diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 2220a01c..4199c013 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -12,6 +12,8 @@ import { } from '../engine'; import { DateRange } from '../range'; import { + CameraManagerEngineCapabilities, + CameraManagerMediaCapabilities, DataQuery, Engine, EventQuery, @@ -760,4 +762,17 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } return seekMilliseconds / 1000; } + + public getCapabilities(): CameraManagerEngineCapabilities { + return { + canFavoriteEvents: true, + canFavoriteRecordings: false, + } + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { + return { + canFavorite: ViewMediaClassifier.isEvent(media) + } + } } diff --git a/src/camera/manager.ts b/src/camera/manager.ts index 4e992dd3..caa7c265 100644 --- a/src/camera/manager.ts +++ b/src/camera/manager.ts @@ -2,6 +2,8 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig } from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { + CameraManagerCapabilities, + CameraManagerMediaCapabilities, DataQuery, EventQuery, EventQueryResults, @@ -312,6 +314,26 @@ export class CameraManager { 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, diff --git a/src/camera/types.ts b/src/camera/types.ts index 6c5b1ed8..109fd439 100644 --- a/src/camera/types.ts +++ b/src/camera/types.ts @@ -74,6 +74,17 @@ export interface MediaMetadata { days?: Set; } +interface BaseCapabilities { + canFavoriteEvents: boolean; + canFavoriteRecordings: boolean; +} + +export type CameraManagerCapabilities = BaseCapabilities; +export type CameraManagerEngineCapabilities = BaseCapabilities; +export interface CameraManagerMediaCapabilities { + canFavorite: boolean; +} + // =========== // Event Query // =========== @@ -107,6 +118,8 @@ export interface EventQueryResults extends QueryResults { export interface RecordingQuery extends MediaQuery { type: QueryType.Recording; + + favorite?: boolean; } export type PartialRecordingQuery = Partial; diff --git a/src/components/media-filter-core.ts b/src/components/media-filter-core.ts index 0085e9d3..deb1b895 100644 --- a/src/components/media-filter-core.ts +++ b/src/components/media-filter-core.ts @@ -56,14 +56,14 @@ export interface MediaFilterCoreWhenSelection { custom?: DateRange; } -export enum MediaFilterControl { - mediaType = 'mediaType', - when = 'when', - camera = 'camera', - what = 'what', - where = 'where', - favorite = 'favorite', -} +export type MediaFilterControls = { + mediaType?: boolean; + when?: boolean; + camera?: boolean; + what?: boolean; + where?: boolean; + favorite?: boolean; +}; @customElement('frigate-card-media-filter-core') export class FrigateCardMediaFilterCore extends LitElement { @@ -86,7 +86,7 @@ export class FrigateCardMediaFilterCore extends LitElement { public defaults?: MediaFilterCoreSelection; @property({ attribute: false }) - public controls?: Record; + public controls?: MediaFilterControls; protected _cameraOptions?: ValueLabel[]; protected _whenOptions?: ValueLabel[]; diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 20cc4871..3e98be31 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -16,29 +16,34 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { CameraManager } from '../camera/manager'; +import { + CameraManager, +} from '../camera/manager'; import { DateRange } from '../camera/range'; import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { getCameraTitle } from '../utils/camera'; import { View } from '../view/view'; import { + MediaFilterControls, MediaFilterCoreFavoriteSelection, MediaFilterCoreSelection, MediaFilterCoreWhen, MediaFilterCoreWhenSelection, + MediaFilterMediaType, ValueLabel, } from './media-filter-core'; import './surround.js'; 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 { EventQuery, QueryType, RecordingQuery } from '../camera/types'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import { createViewForEvents, createViewForRecordings } 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'; import mediaFilterStyle from '../scss/media-filter.scss'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; @customElement('frigate-card-media-filter') export class FrigateCardMediaFilter extends LitElement { @@ -96,35 +101,68 @@ export class FrigateCardMediaFilter extends LitElement { protected async _mediaFilterHandler( ev: CustomEvent, ): Promise { - if (!this.cameras || !this.cameraManager || !this.hass || !this.view) { + const mediaFilter = ev.detail; + if ( + !this.cameras || + !this.cameraManager || + !this.hass || + !this.view || + !mediaFilter.mediaType + ) { return; } - const mediaFilter = ev.detail; + const convertedTime = this._convertWhenToDateRange(mediaFilter.when); const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); - const query: EventQuery = { - type: QueryType.Event, - cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), - ...(mediaFilter.what && { what: mediaFilter.what }), - ...(mediaFilter.where && { where: mediaFilter.where }), - ...(convertedFavorite !== null && { favorite: convertedFavorite }), - ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), - ...(this.mediaLimit && { limit: this.mediaLimit }), - }; + if ( + mediaFilter.mediaType === MediaFilterMediaType.Clips || + mediaFilter.mediaType === MediaFilterMediaType.Snapshots + ) { + const query: EventQuery = { + type: QueryType.Event, + cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + ...(mediaFilter.what && { what: mediaFilter.what }), + ...(mediaFilter.where && { where: mediaFilter.where }), + ...(convertedFavorite !== null && { favorite: convertedFavorite }), + ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + ...(this.mediaLimit && { limit: this.mediaLimit }), + ...(mediaFilter.mediaType === MediaFilterMediaType.Clips && { hasClip: true }), + ...(mediaFilter.mediaType === MediaFilterMediaType.Snapshots && { hasSnapshot: true }) + }; - ( - await createViewForEvents( - this, - this.hass, - this.cameraManager, - this.cameras, - this.view, - { - query: new EventMediaQueries([query]), - }, - ) - )?.dispatchChangeEvent(this); + ( + await createViewForEvents( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + { + query: new EventMediaQueries([query]), + }, + ) + )?.dispatchChangeEvent(this); + } else if (mediaFilter.mediaType === MediaFilterMediaType.Recordings) { + const query: RecordingQuery = { + type: QueryType.Recording, + cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), + ...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), + }; + + ( + await createViewForRecordings( + this, + this.hass, + this.cameraManager, + this.cameras, + this.view, + { + query: new RecordingMediaQueries([query]), + }, + ) + )?.dispatchChangeEvent(this); + } } protected willUpdate(changedProps: PropertyValues): void { @@ -147,12 +185,32 @@ export class FrigateCardMediaFilter extends LitElement { } protected render(): TemplateResult | void { + const areEvents = !!( + this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query) + ); + const areRecordings = !!( + this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query) + ); + const managerCapabilities = this.cameraManager?.getCapabilities(); + + // Which media controls are shown depends on the view. + const controls: MediaFilterControls = { + what: areEvents, + where: areEvents, + favorite: areEvents + ? !!managerCapabilities?.canFavoriteEvents + : areRecordings + ? !!managerCapabilities?.canFavoriteRecordings + : false, + }; + return html` `; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index dcdd7b52..c9de4450 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -1,5 +1,12 @@ import format from 'date-fns/format'; -import { CSSResult, html, LitElement, PropertyValues, TemplateResult, unsafeCSS } from 'lit'; +import { + CSSResult, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { localize } from '../localize/localize.js'; @@ -282,6 +289,12 @@ export class FrigateCardThumbnail extends LitElement { // Only show timeline control if the recording has a start & end time. (this.media.getStartTime() && this.media.getEndTime())); + const shouldShowFavoriteControl = + this.show_favorite_control && + this.media && + this.hass && + this.cameraManager?.getMediaCapabilities(this.media)?.canFavorite; + return html` ${ViewMediaClassifier.isEvent(this.media) ? html`` : html``} - ${this.show_favorite_control && this.media && this.hass + ${shouldShowFavoriteControl ? html` => { let query: EventMediaQueries; + const cameraIDs: Set = options?.cameraIDs + ? options.cameraIDs + : new Set(getAllDependentCameras(cameras, view.camera)); + if (options?.query) { query = options.query; } else { - const cameraIDs: Set = options?.cameraIDs - ? options.cameraIDs - : new Set(getAllDependentCameras(cameras, view.camera)); - const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, { ...(options?.limit && { limit: options.limit }), ...(options?.mediaType === 'clips' && { hasClip: true }), @@ -64,20 +68,21 @@ export const createViewForEvents = async ( query = new EventMediaQueries(eventQueries); } - let queryResults: MediaQueriesResults | null; - try { - queryResults = await cameraManager.executeMediaQueries(hass, query); - } catch (e) { - errorToConsole(e as Error); - dispatchFrigateCardErrorEvent(element, e as Error); + if (!query) { return null; } - return view?.evolve({ - view: options?.targetView, - query: query, - queryResults: queryResults, - }); + return executeMediaQueryForView( + element, + hass, + cameraManager, + view, + query, + options?.targetView ?? 'clips', + { + cameraIDs: cameraIDs, + }, + ); }; /** @@ -129,6 +134,7 @@ export const createViewForRecordings = async ( cameras: Map, view: View, options?: { + query?: RecordingMediaQueries; cameraIDs?: Set; targetView?: 'recording' | 'recordings'; targetTime?: Date; @@ -140,16 +146,48 @@ export const createViewForRecordings = async ( ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); - const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { - ...(options?.start && { start: options.start }), - ...(options?.end && { end: options.end }), - }); + let query: RecordingMediaQueries; + if (options?.query) { + query = options.query; + } else { + const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { + ...(options?.start && { start: options.start }), + ...(options?.end && { end: options.end }), + }); - if (!recordingQueries) { - return null; + if (!recordingQueries) { + return null; + } + + query = new RecordingMediaQueries(recordingQueries); } - const query = new RecordingMediaQueries(recordingQueries); + return executeMediaQueryForView( + element, + hass, + cameraManager, + view, + query, + options?.targetView ?? 'recordings', + { + cameraIDs: cameraIDs, + ...(options?.targetTime && { targetTime: options.targetTime }), + }, + ); +}; + +const executeMediaQueryForView = async ( + element: HTMLElement, + hass: HomeAssistant, + cameraManager: CameraManager, + view: View, + query: MediaQueries, + targetView: FrigateCardView, + options?: { + cameraIDs: Set; + targetTime?: Date; + }, +): Promise => { let queryResults: MediaQueriesResults | null; try { @@ -162,9 +200,9 @@ export const createViewForRecordings = async ( let viewerContext: ViewContext | undefined = {}; const mediaArray = queryResults?.getResults(); - if (queryResults && mediaArray && options?.targetTime) { + if (queryResults && mediaArray && options?.targetTime && options.cameraIDs) { queryResults.selectBestResult((media) => - findClosestMediaIndex(media, options.targetTime as Date, cameraIDs), + findClosestMediaIndex(media, options.targetTime as Date, options.cameraIDs), ); viewerContext = { mediaViewer: { @@ -176,7 +214,7 @@ export const createViewForRecordings = async ( return ( view ?.evolve({ - view: options?.targetView ? options.targetView : 'recording', + view: targetView, query: query, queryResults: queryResults, }) @@ -215,7 +253,7 @@ export const findClosestMediaIndex = ( if (media.includesTime(targetTime)) { const start = media.getStartTime(); const end = media.getEndTime(); - if (!refPoint || !start || !end) { + if (!refPoint || !start || !end) { return i; } const delta = diff --git a/src/view/view.ts b/src/view/view.ts index 5386fcf3..a2fba263 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -3,6 +3,7 @@ // - 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. +// - TODO: Debug statement in card wide config. // Gallery: // - TODO: Event gallery show_details default does not work.