Improve media selection logic.
This commit is contained in:
@@ -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<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(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): CameraManagerEngine[] | null {
|
||||
|
||||
@@ -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<string, CameraConfig>,
|
||||
): Promise<MediaMetadata | null>;
|
||||
|
||||
getCapabilities(): CameraManagerEngineCapabilities | null;
|
||||
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,6 +74,17 @@ export interface MediaMetadata {
|
||||
days?: Set<string>;
|
||||
}
|
||||
|
||||
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<RecordingQuery>;
|
||||
|
||||
|
||||
@@ -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<MediaFilterControl, boolean>;
|
||||
public controls?: MediaFilterControls;
|
||||
|
||||
protected _cameraOptions?: ValueLabel<string>[];
|
||||
protected _whenOptions?: ValueLabel<MediaFilterCoreWhenSelection>[];
|
||||
|
||||
@@ -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,13 +101,24 @@ export class FrigateCardMediaFilter extends LitElement {
|
||||
protected async _mediaFilterHandler(
|
||||
ev: CustomEvent<MediaFilterCoreSelection>,
|
||||
): 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;
|
||||
}
|
||||
const mediaFilter = ev.detail;
|
||||
|
||||
const convertedTime = this._convertWhenToDateRange(mediaFilter.when);
|
||||
const convertedFavorite = this._convertFavoriteToBoolean(mediaFilter.favorite);
|
||||
|
||||
if (
|
||||
mediaFilter.mediaType === MediaFilterMediaType.Clips ||
|
||||
mediaFilter.mediaType === MediaFilterMediaType.Snapshots
|
||||
) {
|
||||
const query: EventQuery = {
|
||||
type: QueryType.Event,
|
||||
cameraIDs: mediaFilter.cameraIDs ?? new Set(this.cameras.keys()),
|
||||
@@ -111,6 +127,8 @@ export class FrigateCardMediaFilter extends LitElement {
|
||||
...(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 })
|
||||
};
|
||||
|
||||
(
|
||||
@@ -125,6 +143,26 @@ export class FrigateCardMediaFilter extends LitElement {
|
||||
},
|
||||
)
|
||||
)?.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` <frigate-card-media-filter-core
|
||||
.hass=${this.hass}
|
||||
.whenOptions=${this._mediaMetadataController?.whenOptions}
|
||||
.cameraOptions=${this._cameraOptions}
|
||||
.whatOptions=${this._mediaMetadataController?.whatOptions}
|
||||
.whereOptions=${this._mediaMetadataController?.whereOptions}
|
||||
.controls=${controls}
|
||||
@frigate-card:media-filter-core:change=${this._mediaFilterHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-media-filter-core>`;
|
||||
|
||||
@@ -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`<frigate-card-thumbnail-feature-event
|
||||
aria-label="${title ?? ''}"
|
||||
@@ -299,7 +312,7 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
.date=${this.media.getStartTime() ?? undefined}
|
||||
></frigate-card-thumbnail-feature-recording>`
|
||||
: html``}
|
||||
${this.show_favorite_control && this.media && this.hass
|
||||
${shouldShowFavoriteControl
|
||||
? html` <ha-icon
|
||||
class="${classMap(starClasses)}"
|
||||
icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'}
|
||||
|
||||
+57
-19
@@ -3,7 +3,11 @@ import sub from 'date-fns/sub';
|
||||
import { ViewContext } from 'view';
|
||||
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||
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 { getAllDependentCameras } from './camera.js';
|
||||
import { ViewMedia } from '../view/media';
|
||||
@@ -46,13 +50,13 @@ export const createViewForEvents = async (
|
||||
},
|
||||
): Promise<View | null> => {
|
||||
let query: EventMediaQueries;
|
||||
if (options?.query) {
|
||||
query = options.query;
|
||||
} else {
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||
|
||||
if (options?.query) {
|
||||
query = options.query;
|
||||
} else {
|
||||
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<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
query?: RecordingMediaQueries;
|
||||
cameraIDs?: Set<string>;
|
||||
targetView?: 'recording' | 'recordings';
|
||||
targetTime?: Date;
|
||||
@@ -140,6 +146,10 @@ export const createViewForRecordings = async (
|
||||
? options.cameraIDs
|
||||
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||
|
||||
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 }),
|
||||
@@ -149,7 +159,35 @@ export const createViewForRecordings = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = new RecordingMediaQueries(recordingQueries);
|
||||
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;
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user