Improve media selection logic.

This commit is contained in:
Dermot Duffy
2023-01-29 08:02:36 -08:00
parent 931e9febce
commit 1ad600c231
10 changed files with 243 additions and 65 deletions
+13
View File
@@ -1,4 +1,5 @@
import { CameraConfig } from '../types'; import { CameraConfig } from '../types';
import { ViewMedia } from '../view/media';
import { RecordingSegmentsCache, RequestCache } from './cache'; import { RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine'; import { CameraManagerEngine } from './engine';
import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
@@ -65,6 +66,18 @@ export class CameraManagerEngineFactory {
return output.size ? output : null; 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( public getAllEngines(
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
): CameraManagerEngine[] | null { ): CameraManagerEngine[] | null {
+5
View File
@@ -14,6 +14,8 @@ import {
RecordingQueryResultsMap, RecordingQueryResultsMap,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
CameraManagerEngineCapabilities,
CameraManagerMediaCapabilities,
} from './types'; } from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
@@ -89,4 +91,7 @@ export interface CameraManagerEngine {
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
): Promise<MediaMetadata | null>; ): Promise<MediaMetadata | null>;
getCapabilities(): CameraManagerEngineCapabilities | null;
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
} }
+15
View File
@@ -12,6 +12,8 @@ import {
} from '../engine'; } from '../engine';
import { DateRange } from '../range'; import { DateRange } from '../range';
import { import {
CameraManagerEngineCapabilities,
CameraManagerMediaCapabilities,
DataQuery, DataQuery,
Engine, Engine,
EventQuery, EventQuery,
@@ -760,4 +762,17 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
} }
return seekMilliseconds / 1000; return seekMilliseconds / 1000;
} }
public getCapabilities(): CameraManagerEngineCapabilities {
return {
canFavoriteEvents: true,
canFavoriteRecordings: false,
}
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
return {
canFavorite: ViewMediaClassifier.isEvent(media)
}
}
} }
+22
View File
@@ -2,6 +2,8 @@ import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types.js'; import { CameraConfig } from '../types.js';
import { allPromises, arrayify, setify } from '../utils/basic.js'; import { allPromises, arrayify, setify } from '../utils/basic.js';
import { import {
CameraManagerCapabilities,
CameraManagerMediaCapabilities,
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
@@ -312,6 +314,26 @@ export class CameraManager {
return engine.getMediaDownloadPath(cameraConfig, media); 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( public async favoriteMedia(
hass: HomeAssistant, hass: HomeAssistant,
media: ViewMedia, media: ViewMedia,
+13
View File
@@ -74,6 +74,17 @@ export interface MediaMetadata {
days?: Set<string>; days?: Set<string>;
} }
interface BaseCapabilities {
canFavoriteEvents: boolean;
canFavoriteRecordings: boolean;
}
export type CameraManagerCapabilities = BaseCapabilities;
export type CameraManagerEngineCapabilities = BaseCapabilities;
export interface CameraManagerMediaCapabilities {
canFavorite: boolean;
}
// =========== // ===========
// Event Query // Event Query
// =========== // ===========
@@ -107,6 +118,8 @@ export interface EventQueryResults extends QueryResults {
export interface RecordingQuery extends MediaQuery { export interface RecordingQuery extends MediaQuery {
type: QueryType.Recording; type: QueryType.Recording;
favorite?: boolean;
} }
export type PartialRecordingQuery = Partial<RecordingQuery>; export type PartialRecordingQuery = Partial<RecordingQuery>;
+9 -9
View File
@@ -56,14 +56,14 @@ export interface MediaFilterCoreWhenSelection {
custom?: DateRange; custom?: DateRange;
} }
export enum MediaFilterControl { export type MediaFilterControls = {
mediaType = 'mediaType', mediaType?: boolean;
when = 'when', when?: boolean;
camera = 'camera', camera?: boolean;
what = 'what', what?: boolean;
where = 'where', where?: boolean;
favorite = 'favorite', favorite?: boolean;
} };
@customElement('frigate-card-media-filter-core') @customElement('frigate-card-media-filter-core')
export class FrigateCardMediaFilterCore extends LitElement { export class FrigateCardMediaFilterCore extends LitElement {
@@ -86,7 +86,7 @@ export class FrigateCardMediaFilterCore extends LitElement {
public defaults?: MediaFilterCoreSelection; public defaults?: MediaFilterCoreSelection;
@property({ attribute: false }) @property({ attribute: false })
public controls?: Record<MediaFilterControl, boolean>; public controls?: MediaFilterControls;
protected _cameraOptions?: ValueLabel<string>[]; protected _cameraOptions?: ValueLabel<string>[];
protected _whenOptions?: ValueLabel<MediaFilterCoreWhenSelection>[]; protected _whenOptions?: ValueLabel<MediaFilterCoreWhenSelection>[];
+85 -27
View File
@@ -16,29 +16,34 @@ import {
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera/manager'; import {
CameraManager,
} from '../camera/manager';
import { DateRange } from '../camera/range'; import { DateRange } from '../camera/range';
import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { CameraConfig, ExtendedHomeAssistant } from '../types';
import { getCameraTitle } from '../utils/camera'; import { getCameraTitle } from '../utils/camera';
import { View } from '../view/view'; import { View } from '../view/view';
import { import {
MediaFilterControls,
MediaFilterCoreFavoriteSelection, MediaFilterCoreFavoriteSelection,
MediaFilterCoreSelection, MediaFilterCoreSelection,
MediaFilterCoreWhen, MediaFilterCoreWhen,
MediaFilterCoreWhenSelection, MediaFilterCoreWhenSelection,
MediaFilterMediaType,
ValueLabel, ValueLabel,
} from './media-filter-core'; } from './media-filter-core';
import './surround.js'; import './surround.js';
import './timeline-core.js'; import './timeline-core.js';
import { EventQuery, QueryType } from '../camera/types'; import { EventQuery, QueryType, RecordingQuery } from '../camera/types';
import { EventMediaQueries } from '../view/media-queries'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { createViewForEvents } from '../utils/media-to-view.js'; import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { prettifyTitle } from '../utils/basic'; import { prettifyTitle } from '../utils/basic';
import format from 'date-fns/format'; import format from 'date-fns/format';
import parse from 'date-fns/parse'; import parse from 'date-fns/parse';
import endOfMonth from 'date-fns/endOfMonth'; import endOfMonth from 'date-fns/endOfMonth';
import mediaFilterStyle from '../scss/media-filter.scss'; import mediaFilterStyle from '../scss/media-filter.scss';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
@customElement('frigate-card-media-filter') @customElement('frigate-card-media-filter')
export class FrigateCardMediaFilter extends LitElement { export class FrigateCardMediaFilter extends LitElement {
@@ -96,35 +101,68 @@ export class FrigateCardMediaFilter extends LitElement {
protected async _mediaFilterHandler( protected async _mediaFilterHandler(
ev: CustomEvent<MediaFilterCoreSelection>, ev: CustomEvent<MediaFilterCoreSelection>,
): Promise<void> { ): Promise<void> {
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; return;
} }
const mediaFilter = ev.detail;
const convertedTime = this._convertWhenToDateRange(mediaFilter.when); const convertedTime = this._convertWhenToDateRange(mediaFilter.when);
const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite); const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite);
const query: EventQuery = { if (
type: QueryType.Event, mediaFilter.mediaType === MediaFilterMediaType.Clips ||
cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()), mediaFilter.mediaType === MediaFilterMediaType.Snapshots
...(mediaFilter.what && { what: mediaFilter.what }), ) {
...(mediaFilter.where && { where: mediaFilter.where }), const query: EventQuery = {
...(convertedFavorite !== null && { favorite: convertedFavorite }), type: QueryType.Event,
...(convertedTime && { start: convertedTime.start, end: convertedTime.end }), cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()),
...(this.mediaLimit && { limit: this.mediaLimit }), ...(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( await createViewForEvents(
this, this,
this.hass, this.hass,
this.cameraManager, this.cameraManager,
this.cameras, this.cameras,
this.view, this.view,
{ {
query: new EventMediaQueries([query]), query: new EventMediaQueries([query]),
}, },
) )
)?.dispatchChangeEvent(this); )?.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 { protected willUpdate(changedProps: PropertyValues): void {
@@ -147,12 +185,32 @@ export class FrigateCardMediaFilter extends LitElement {
} }
protected render(): TemplateResult | void { 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` <frigate-card-media-filter-core return html` <frigate-card-media-filter-core
.hass=${this.hass} .hass=${this.hass}
.whenOptions=${this._mediaMetadataController?.whenOptions} .whenOptions=${this._mediaMetadataController?.whenOptions}
.cameraOptions=${this._cameraOptions} .cameraOptions=${this._cameraOptions}
.whatOptions=${this._mediaMetadataController?.whatOptions} .whatOptions=${this._mediaMetadataController?.whatOptions}
.whereOptions=${this._mediaMetadataController?.whereOptions} .whereOptions=${this._mediaMetadataController?.whereOptions}
.controls=${controls}
@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>`;
+15 -2
View File
@@ -1,5 +1,12 @@
import format from 'date-fns/format'; 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 { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { localize } from '../localize/localize.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. // Only show timeline control if the recording has a start & end time.
(this.media.getStartTime() && this.media.getEndTime())); (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) return html` ${ViewMediaClassifier.isEvent(this.media)
? html`<frigate-card-thumbnail-feature-event ? html`<frigate-card-thumbnail-feature-event
aria-label="${title ?? ''}" aria-label="${title ?? ''}"
@@ -299,7 +312,7 @@ export class FrigateCardThumbnail extends LitElement {
.date=${this.media.getStartTime() ?? undefined} .date=${this.media.getStartTime() ?? undefined}
></frigate-card-thumbnail-feature-recording>` ></frigate-card-thumbnail-feature-recording>`
: html``} : html``}
${this.show_favorite_control && this.media && this.hass ${shouldShowFavoriteControl
? html` <ha-icon ? html` <ha-icon
class="${classMap(starClasses)}" class="${classMap(starClasses)}"
icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'} icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'}
+65 -27
View File
@@ -3,7 +3,11 @@ import sub from 'date-fns/sub';
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
import { View } from '../view/view'; import { View } from '../view/view';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../view/media-queries';
import { CameraManager } from '../camera/manager'; import { CameraManager } from '../camera/manager';
import { getAllDependentCameras } from './camera.js'; import { getAllDependentCameras } from './camera.js';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
@@ -46,13 +50,13 @@ export const createViewForEvents = async (
}, },
): Promise<View | null> => { ): Promise<View | null> => {
let query: EventMediaQueries; let query: EventMediaQueries;
const cameraIDs: Set<string> = options?.cameraIDs
? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera));
if (options?.query) { if (options?.query) {
query = options.query; query = options.query;
} else { } else {
const cameraIDs: Set<string> = options?.cameraIDs
? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera));
const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, { const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
...(options?.limit && { limit: options.limit }), ...(options?.limit && { limit: options.limit }),
...(options?.mediaType === 'clips' && { hasClip: true }), ...(options?.mediaType === 'clips' && { hasClip: true }),
@@ -64,20 +68,21 @@ export const createViewForEvents = async (
query = new EventMediaQueries(eventQueries); query = new EventMediaQueries(eventQueries);
} }
let queryResults: MediaQueriesResults | null; if (!query) {
try {
queryResults = await cameraManager.executeMediaQueries(hass, query);
} catch (e) {
errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(element, e as Error);
return null; return null;
} }
return view?.evolve({ return executeMediaQueryForView(
view: options?.targetView, element,
query: query, hass,
queryResults: queryResults, cameraManager,
}); view,
query,
options?.targetView ?? 'clips',
{
cameraIDs: cameraIDs,
},
);
}; };
/** /**
@@ -129,6 +134,7 @@ export const createViewForRecordings = async (
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
view: View, view: View,
options?: { options?: {
query?: RecordingMediaQueries;
cameraIDs?: Set<string>; cameraIDs?: Set<string>;
targetView?: 'recording' | 'recordings'; targetView?: 'recording' | 'recordings';
targetTime?: Date; targetTime?: Date;
@@ -140,16 +146,48 @@ export const createViewForRecordings = async (
? options.cameraIDs ? options.cameraIDs
: new Set(getAllDependentCameras(cameras, view.camera)); : new Set(getAllDependentCameras(cameras, view.camera));
const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { let query: RecordingMediaQueries;
...(options?.start && { start: options.start }), if (options?.query) {
...(options?.end && { end: options.end }), query = options.query;
}); } else {
const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
...(options?.start && { start: options.start }),
...(options?.end && { end: options.end }),
});
if (!recordingQueries) { if (!recordingQueries) {
return null; 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<string>;
targetTime?: Date;
},
): Promise<View | null> => {
let queryResults: MediaQueriesResults | null; let queryResults: MediaQueriesResults | null;
try { try {
@@ -162,9 +200,9 @@ export const createViewForRecordings = async (
let viewerContext: ViewContext | undefined = {}; let viewerContext: ViewContext | undefined = {};
const mediaArray = queryResults?.getResults(); const mediaArray = queryResults?.getResults();
if (queryResults && mediaArray && options?.targetTime) { if (queryResults && mediaArray && options?.targetTime && options.cameraIDs) {
queryResults.selectBestResult((media) => queryResults.selectBestResult((media) =>
findClosestMediaIndex(media, options.targetTime as Date, cameraIDs), findClosestMediaIndex(media, options.targetTime as Date, options.cameraIDs),
); );
viewerContext = { viewerContext = {
mediaViewer: { mediaViewer: {
@@ -176,7 +214,7 @@ export const createViewForRecordings = async (
return ( return (
view view
?.evolve({ ?.evolve({
view: options?.targetView ? options.targetView : 'recording', view: targetView,
query: query, query: query,
queryResults: queryResults, queryResults: queryResults,
}) })
@@ -215,7 +253,7 @@ export const findClosestMediaIndex = (
if (media.includesTime(targetTime)) { if (media.includesTime(targetTime)) {
const start = media.getStartTime(); const start = media.getStartTime();
const end = media.getEndTime(); const end = media.getEndTime();
if (!refPoint || !start || !end) { if (!refPoint || !start || !end) {
return i; return i;
} }
const delta = const delta =
+1
View File
@@ -3,6 +3,7 @@
// - 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. // - TODO: View a media in the gallery from September, then notice timeline missing the item.
// - TODO: Debug statement in card wide config.
// Gallery: // Gallery:
// - TODO: Event gallery show_details default does not work. // - TODO: Event gallery show_details default does not work.