perf: Change media fetches to be asynchronous to view render (#1702)

This is a fairly non-trivial change in terms of consequence, so a greater than average chance something breaks. This is necessary since some cameras (e.g. Reolink) are materially slower to fetch media, and this change substantially improves card responsiveness.
This commit is contained in:
Dermot Duffy
2024-12-01 20:57:48 -08:00
committed by GitHub
parent c69fdff5e3
commit 9ac6134446
23 changed files with 1489 additions and 829 deletions
+2
View File
@@ -691,6 +691,8 @@ export class CameraManager {
_queries,
', Results:',
results,
', Options:',
engineOptions ?? {},
']',
);
return results;
+9 -241
View File
@@ -1,4 +1,3 @@
import { sub } from 'date-fns';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
@@ -6,27 +5,17 @@ import {
ViewDisplayMode,
} from '../../config/types';
import { localize } from '../../localize/localize';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { View, ViewParameters } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { CardViewAPI } from '../types';
import { QueryExecutor } from './query-executor';
import {
QueryExecutorOptions,
QueryWithResults,
ViewFactoryOptions,
ViewIncompatible,
ViewNoCameraError,
} from './types';
import { applyViewModifiers } from './modifiers';
import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types';
export class ViewFactory {
protected _api: CardViewAPI;
protected _executor: QueryExecutor;
constructor(api: CardViewAPI, executor?: QueryExecutor) {
constructor(api: CardViewAPI) {
this._api = api;
this._executor = executor ?? new QueryExecutor(api);
}
public getViewDefault(options?: ViewFactoryOptions): View | null {
@@ -91,7 +80,7 @@ export class ViewFactory {
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
cameraID = viewCameraIDs.keys().next().value ?? null;
}
if (!cameraID) {
@@ -149,239 +138,18 @@ export class ViewFactory {
? options.baseView.evolve(viewParameters)
: new View(viewParameters);
if (options?.modifiers) {
options.modifiers.forEach((modifier) => modifier.modify(view));
}
applyViewModifiers(view, options?.modifiers);
return view;
}
public async getViewDefaultWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewDefault(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}
public async getViewByParametersWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewByParameters(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}
public async getViewByParametersWithExistingQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
const view = this.getViewByParameters(options);
if (view?.query) {
view.queryResults = await this._executor.execute(
view.query,
options?.queryExecutorOptions,
);
}
return view;
}
protected async _executeNewQuery(
view: View | null,
options?: ViewFactoryOptions,
): Promise<View | null> {
const config = this._api.getConfigManager().getConfig();
if (
!config ||
/* istanbul ignore next: this path cannot be reached as the only way for
view to be null here, is if the config is also null -- @preserve */
!view
) {
return null;
}
const executeMediaQuery = async (
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
): Promise<boolean> => {
/* istanbul ignore if: this path cannot be reached -- @preserve */
if (!mediaType) {
return false;
}
return await this._executeMediaQuery(
view,
mediaType === 'recordings' ? 'recordings' : 'events',
{
eventsMediaType: mediaType === 'recordings' ? undefined : mediaType,
executorOptions: options?.queryExecutorOptions,
},
);
};
// Implementation note: For new queries, if the query itself fails that is
// just ignored and the view is returned anyway (e.g. if the user changes to
// live but the thumbnail fetch fails, it is better to change to live and
// show no thumbnails than not change to live).
const mediaType = view.getDefaultMediaType();
const baseView = options?.baseView;
const switchingToGalleryFromViewer =
baseView?.isViewerView() && view.isGalleryView();
const alreadyHasMatchingQuery =
mediaType === MediaQueriesClassifier.getMediaType(baseView?.query);
if (
switchingToGalleryFromViewer &&
alreadyHasMatchingQuery &&
baseView?.query &&
baseView?.queryResults
) {
// If the user is currently using the viewer, and then switches to the
// gallery we make an attempt to keep the query/queryResults the same so
// the gallery can be used to click back and forth to the viewer, and the
// selected media can be centered in the gallery. See the matching code in
// `updated()` in `gallery.ts`. We specifically must ensure that the new
// target media of the gallery (e.g. clips, snapshots or recordings) is
// equal to the queries that are currently used in the viewer.
//
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
view.query = baseView.query;
view.queryResults = baseView.queryResults;
} else {
switch (view.view) {
case 'live':
if (config.live.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(
config.live.controls.thumbnails.media_type === 'recordings'
? 'recordings'
: config.live.controls.thumbnails.events_media_type,
);
}
break;
case 'media':
// If the user is looking at media in the `media` view and then
// changes camera (via the menu) it should default to showing clips
// for the new camera.
if (baseView && view.camera !== baseView.camera) {
await executeMediaQuery('clips');
}
break;
// Gallery views:
case 'clips':
case 'snapshots':
case 'recordings':
await executeMediaQuery(mediaType);
break;
// Viewer views:
case 'clip':
case 'snapshot':
case 'recording':
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(mediaType);
}
break;
}
}
this._setOrRemoveTimelineWindow(view);
this._setOrRemoveSeekTime(
view,
options?.queryExecutorOptions?.selectResult?.time?.time,
);
return view;
}
protected _setOrRemoveTimelineWindow(view: View): void {
if (view.is('live')) {
// For live views, always force the timeline to now, regardless of
// presence or not of events.
const now = new Date();
const liveConfig = this._api.getConfigManager().getConfig()?.live;
/* istanbul ignore if: this if branch cannot be reached as if the config is
empty this function is never called -- @preserve */
if (!liveConfig) {
return;
}
view.mergeInContext({
// Force the window to start at the most recent time, not
// necessarily when the most recent event/recording was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
timeline: {
window: {
start: sub(now, {
seconds: liveConfig.controls.timeline.window_seconds,
}),
end: now,
},
},
});
} else {
// For non-live views stick to default timeline behavior (will select and
// scroll to event).
view.removeContextProperty('timeline', 'window');
}
}
protected _setOrRemoveSeekTime(view: View, time?: Date): void {
if (time) {
view.mergeInContext({
mediaViewer: {
seek: time,
},
});
} else {
view.removeContextProperty('mediaViewer', 'seek');
}
}
protected async _executeMediaQuery(
view: View,
mediaType: 'events' | 'recordings',
options?: {
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
},
): Promise<boolean> {
const queryWithResults: QueryWithResults | null =
mediaType === 'events'
? await this._executor.executeDefaultEventQuery({
...(!view.isGrid() && { cameraID: view.camera }),
eventsMediaType: options?.eventsMediaType,
executorOptions: options?.executorOptions,
})
: mediaType === 'recordings'
? await this._executor.executeDefaultRecordingQuery({
...(!view.isGrid() && { cameraID: view.camera }),
executorOptions: options?.executorOptions,
})
: /* istanbul ignore next -- @preserve */
null;
if (!queryWithResults) {
return false;
}
view.query = queryWithResults.query;
view.queryResults = queryWithResults.queryResults;
return true;
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
config: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
@@ -389,10 +157,10 @@ export class ViewFactory {
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
mode = config.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
mode = config.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
@@ -0,0 +1,9 @@
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export const applyViewModifiers = (
view: View,
modifiers?: ViewModifier[] | null,
): void => {
modifiers?.forEach((modifier) => modifier.modify(view));
};
@@ -0,0 +1,26 @@
import { MediaQueries } from '../../../view/media-queries';
import { MediaQueriesResults } from '../../../view/media-queries-results';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SetQueryViewModifier implements ViewModifier {
protected _query?: MediaQueries | null;
protected _queryResults?: MediaQueriesResults | null;
constructor(options?: {
query?: MediaQueries | null;
queryResults?: MediaQueriesResults | null;
}) {
this._query = options?.query;
this._queryResults = options?.queryResults;
}
public modify(view: View): void {
if (this._query !== undefined) {
view.query = this._query;
}
if (this._queryResults !== undefined) {
view.queryResults = this._queryResults;
}
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryWithResults } from './types';
import { QueryExecutorOptions, QueryExecutorResult } from './types';
export class QueryExecutor {
protected _api: CardViewAPI;
@@ -22,7 +22,7 @@ export class QueryExecutor {
cameraID?: string;
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
}): Promise<QueryExecutorResult | null> {
const capabilitySearch: CapabilitySearchOptions =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
@@ -61,7 +61,7 @@ export class QueryExecutor {
public async executeDefaultRecordingQuery(options?: {
cameraID?: string;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
}): Promise<QueryExecutorResult | null> {
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
+2 -2
View File
@@ -26,7 +26,7 @@ export interface QueryExecutorOptions {
useCache?: boolean;
}
export interface QueryWithResults {
export interface QueryExecutorResult {
query: MediaQueries;
queryResults: MediaQueriesResults;
}
@@ -73,7 +73,7 @@ export interface ViewManagerInterface {
setViewWithMergedContext(context: ViewContext | null): void;
isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
hasMajorMediaChange(oldView?: View | null): boolean;
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
}
export class ViewNoCameraError extends FrigateCardError {}
+161 -30
View File
@@ -2,21 +2,42 @@ import { ViewContext } from 'view';
import { FrigateCardView } from '../../config/types';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { View } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
import { ViewFactoryOptions, ViewManagerEpoch, ViewManagerInterface } from './types';
import {
QueryExecutorOptions,
ViewFactoryOptions,
ViewManagerEpoch,
ViewManagerInterface,
ViewModifier,
} from './types';
import { ViewQueryExecutor } from './view-query-executor';
import { applyViewModifiers } from './modifiers';
export class ViewManager implements ViewManagerInterface {
protected _view: View | null = null;
protected _factory: ViewFactory;
protected _viewFactory: ViewFactory;
protected _viewQueryExecutor: ViewQueryExecutor;
protected _api: CardViewAPI;
protected _epoch: ViewManagerEpoch = this._createEpoch();
constructor(api: CardViewAPI, factory?: ViewFactory) {
// Used to mark as a view as "loading" with a given index. Each subsequent
// async update will use a higher index.
protected _loadingIndex = 1;
constructor(
api: CardViewAPI,
options?: {
viewFactory?: ViewFactory;
viewQueryExecutor?: ViewQueryExecutor;
},
) {
this._api = api;
this._factory = factory ?? new ViewFactory(api);
this._viewFactory = options?.viewFactory ?? new ViewFactory(api);
this._viewQueryExecutor = options?.viewQueryExecutor ?? new ViewQueryExecutor(api);
}
public getEpoch(): ViewManagerEpoch {
@@ -42,63 +63,168 @@ export class ViewManager implements ViewManagerInterface {
}
setViewDefault = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(this._factory.getViewDefault.bind(this._factory), options);
this._setViewGeneric(
this._viewFactory.getViewDefault.bind(this._viewFactory),
options,
);
setViewByParameters = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(this._factory.getViewByParameters.bind(this._factory), options);
this._setViewGeneric(
this._viewFactory.getViewByParameters.bind(this._viewFactory),
options,
);
setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewDefaultWithNewQuery.bind(this._factory),
await this._setViewThenModifyAsync(
this._viewFactory.getViewDefault.bind(this._viewFactory),
this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
options,
);
setViewByParametersWithNewQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithNewQuery.bind(this._factory),
await this._setViewThenModifyAsync(
this._viewFactory.getViewByParameters.bind(this._viewFactory),
this._viewQueryExecutor.getNewQueryModifiers.bind(this._viewQueryExecutor),
options,
);
setViewByParametersWithExistingQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithExistingQuery.bind(this._factory),
await this._setViewThenModifyAsync(
this._viewFactory.getViewByParameters.bind(this._viewFactory),
this._viewQueryExecutor.getExistingQueryModifiers.bind(this._viewQueryExecutor),
options,
);
protected _setViewGeneric(
factoryFunc: (options?: ViewFactoryOptions) => View | null,
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
let view: View | null = null;
try {
view = factoryFunc({
view = viewFactoryFunc({
baseView: this._view,
...options,
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
}
protected async _setViewGenericAsync(
factoryFunc: (options?: ViewFactoryOptions) => Promise<View | null>,
protected _markViewLoadingQuery(view: View, index: number): View {
return view.mergeInContext({ loading: { query: index } });
}
protected _markViewAsNotLoadingQuery(view: View): View {
return view.removeContextProperty('loading', 'query');
}
protected async _setViewThenModifyAsync(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
viewModifiersFunc: (
view: View,
queryExecutorOptions?: QueryExecutorOptions,
) => Promise<ViewModifier[] | null>,
options?: ViewFactoryOptions,
): Promise<void> {
let view: View | null = null;
let initialView: View | null = null;
try {
view = await factoryFunc({
initialView = viewFactoryFunc({
baseView: this._view,
...options,
params: {
query: null,
queryResults: null,
...options?.params,
},
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
if (!initialView) {
return;
}
if (this._view && this._shouldAdoptQueryAndResults(initialView)) {
initialView.query = this._view.query;
initialView.queryResults = this._view.queryResults;
this._markViewAsNotLoadingQuery(initialView);
this._setView(initialView);
return;
}
// Mark the view as loading with the current value of _updateIndex. This is
// used to ensure that the loading state is subsequently only removed for
// _this_ async update.
const loadingIndex = this._loadingIndex++;
this._markViewLoadingQuery(initialView, loadingIndex);
this._setView(initialView);
let viewModifiers: ViewModifier[] | null = null;
let error: Error | null = null;
try {
viewModifiers = await viewModifiersFunc(
initialView,
options?.queryExecutorOptions,
);
} catch (e) {
error = e as Error;
}
if (this._view && this.hasMajorMediaChange(this._view, initialView)) {
// If there has been a major media change in the time async operations
// have occurred, ignore the result. For example: A slow Reolink query is
// dispatched, the user changes the view in the interim, then the query
// returns -- it should not be applied, nor should any errors be shown. On
// the contrary, small changes such as the user zooming in are fine to
// merge into the resultant view.
if (this._view.context?.loading?.query === loadingIndex) {
this._setView(this._markViewAsNotLoadingQuery(this._view.clone()));
}
return;
}
if (error) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return;
}
/* istanbul ignore if: the if path cannot be reached as the view is set
above -- @preserve */
if (!this._view) {
return;
}
const newView = this._view.clone();
if (this._view.context?.loading?.query === loadingIndex) {
this._markViewAsNotLoadingQuery(newView);
}
applyViewModifiers(newView, viewModifiers);
this._setView(newView);
}
protected _shouldAdoptQueryAndResults(newView: View): boolean {
// If the user is currently using the viewer, and then switches to the
// gallery we make an attempt to keep the query/queryResults the same so
// the gallery can be used to click back and forth to the viewer, and the
// selected media can be centered in the gallery. See the matching code in
// `updated()` in `gallery.ts`. We specifically must ensure that the new
// target media of the gallery (e.g. clips, snapshots or recordings) is
// equal to the queries that are currently used in the viewer.
//
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
const switchingFromViewerToGallery =
this._view?.isViewerView() && newView?.isGalleryView();
const newMediaType = newView?.getDefaultMediaType();
const alreadyHasMatchingQuery =
MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
}
public setViewWithMergedContext(context: ViewContext | null): void {
@@ -116,20 +242,23 @@ export class ViewManager implements ViewManagerInterface {
* @param oldView The previous view.
* @returns True if the view change is a real media change.
*/
public hasMajorMediaChange(oldView?: View | null): boolean {
public hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean {
const compareView = newView ?? this._view;
return (
!!oldView !== !!this._view ||
oldView?.view !== this._view?.view ||
oldView?.camera !== this._view?.camera ||
!!oldView !== !!compareView ||
oldView?.view !== compareView?.view ||
oldView?.camera !== compareView?.camera ||
// When in live mode, take overrides (substreams) into account in deciding
// if this is a major media change.
(this._view?.view === 'live' &&
(compareView?.view === 'live' &&
oldView &&
getStreamCameraID(oldView) !== getStreamCameraID(this._view)) ||
getStreamCameraID(oldView) !== getStreamCameraID(compareView)) ||
// When in the live view, the queryResults contain the events that
// happened in the past -- not reflective of the actual live media viewer
// the user is seeing.
(this._view?.view !== 'live' && oldView?.queryResults !== this._view?.queryResults)
(compareView?.view !== 'live' &&
oldView?.queryResults !== compareView?.queryResults)
);
}
@@ -139,12 +268,14 @@ export class ViewManager implements ViewManagerInterface {
// query actions (called at least once per render cycle).
// Related: https://github.com/dermotduffy/frigate-hass-card/issues/1200
if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
await this.setViewDefaultWithNewQuery({ failSafe: true });
// This is not awaited to allow the initialization to complete before the
// query is answered.
this.setViewDefaultWithNewQuery({ failSafe: true });
}
return true;
};
protected _setView(view: View | null): void {
protected _setView(view: Readonly<View> | null): void {
const oldView = this._view;
log(
@@ -0,0 +1,178 @@
import { sub } from 'date-fns';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { View } from '../../view/view';
import { CardViewAPI } from '../types';
import { MergeContextViewModifier } from './modifiers/merge-context';
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
import { SetQueryViewModifier } from './modifiers/set-query';
import { QueryExecutor } from './query-executor';
import { QueryExecutorOptions, ViewModifier } from './types';
/**
* This class executes media queries and returns an array of ViewModifiers that
* can be applied to a view. This allows a view to be set when the user acts,
* and if a query is made as part of this view the result can be applied later.
*/
export class ViewQueryExecutor {
protected _api: CardViewAPI;
protected _executor: QueryExecutor;
constructor(api: CardViewAPI, executor?: QueryExecutor) {
this._api = api;
this._executor = executor ?? new QueryExecutor(api);
}
public async getExistingQueryModifiers(
view: View,
queryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> {
return view.query
? [
new SetQueryViewModifier({
queryResults: await this._executor.execute(view.query, queryExecutorOptions),
}),
]
: [];
}
public async getNewQueryModifiers(
view: View,
queryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> {
return await this._executeNewQuery(view, {
useCache: false,
...queryExecutorOptions,
});
}
protected async _executeNewQuery(
view: View,
queryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
const mediaType = view?.getDefaultMediaType();
const viewModifiers: ViewModifier[] = [];
const executeMediaQuery = async (
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
): Promise<ViewModifier[]> => {
/* istanbul ignore if: this path cannot be reached -- @preserve */
if (!mediaType) {
return [];
}
const results =
mediaType === 'recordings'
? await this._executor.executeDefaultRecordingQuery({
...(!view.isGrid() && { cameraID: view.camera }),
executorOptions: queryExecutorOptions,
})
: mediaType === 'clips' || mediaType === 'snapshots' || mediaType === 'all'
? await this._executor.executeDefaultEventQuery({
...(!view.isGrid() && { cameraID: view.camera }),
eventsMediaType: mediaType,
executorOptions: queryExecutorOptions,
})
: /* istanbul ignore next -- @preserve */
null;
return results ? [new SetQueryViewModifier(results)] : [];
};
switch (view.view) {
case 'live':
if (config.live.controls.thumbnails.mode !== 'none') {
viewModifiers.push(
...(await executeMediaQuery(
config.live.controls.thumbnails.media_type === 'recordings'
? 'recordings'
: config.live.controls.thumbnails.events_media_type,
)),
);
}
break;
case 'media':
// If the user is looking at media in the `media` view and then
// changes camera (via the menu) it should default to showing clips
// for the new camera.
viewModifiers.push(...(await executeMediaQuery('clips')));
break;
// Gallery views:
case 'clips':
case 'snapshots':
case 'recordings':
viewModifiers.push(...(await executeMediaQuery(mediaType)));
break;
// Viewer views:
case 'clip':
case 'snapshot':
case 'recording':
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
viewModifiers.push(...(await executeMediaQuery(mediaType)));
}
break;
}
viewModifiers.push(...this._getTimelineWindowViewModifier(view));
viewModifiers.push(
...this._getSeekTimeModifier(queryExecutorOptions?.selectResult?.time?.time),
);
return viewModifiers;
}
protected _getTimelineWindowViewModifier(view: View): ViewModifier[] {
if (view.is('live')) {
// For live views, always force the timeline to now, regardless of
// presence or not of events.
const now = new Date();
const liveConfig = this._api.getConfigManager().getConfig()?.live;
/* istanbul ignore if: this if branch cannot be reached as if the config is
empty this function is never called -- @preserve */
if (!liveConfig) {
return [];
}
return [
new MergeContextViewModifier({
// Force the window to start at the most recent time, not
// necessarily when the most recent event/recording was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
timeline: {
window: {
start: sub(now, {
seconds: liveConfig.controls.timeline.window_seconds,
}),
end: now,
},
},
}),
];
} else {
// For non-live views stick to default timeline behavior (will select and
// scroll to event).
return [new RemoveContextPropertyViewModifier('timeline', 'window')];
}
}
protected _getSeekTimeModifier(time?: Date): ViewModifier[] {
if (time) {
return [
new MergeContextViewModifier({
mediaViewer: {
seek: time,
},
}),
];
} else {
return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')];
}
}
}
+5 -1
View File
@@ -401,10 +401,14 @@ export class FrigateCardGalleryCore extends LitElement {
// handle (as typical), but rather directly rendering the message into the
// gallery. This is to allow the filter to still be available when a given
// filter selection returns no media.
const loadingMedia = !!view?.context?.loading?.query;
return renderMessage({
type: 'info',
message: localize('common.no_media'),
message: loadingMedia
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: loadingMedia,
});
}
+6 -1
View File
@@ -56,10 +56,15 @@ export class FrigateCardViewer extends LitElement {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user pans into an area with no
// media.
const loadingMedia =
!!this.viewManagerEpoch.manager.getView()?.context?.loading?.query;
return renderMessage({
type: 'info',
message: localize('common.no_media'),
message: loadingMedia
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
dotdotdot: loadingMedia,
});
}
+35
View File
@@ -127,6 +127,39 @@
},
"url": "URL de la interfície d'usuari de MotionEye"
},
"proxy": {
"dynamic": "",
"editor_label": "",
"media": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
},
"ssl_ciphers": {
"auto": "",
"default": "",
"editor_label": "",
"insecure": "",
"intermediate": "",
"modern": ""
},
"ssl_verification": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
}
},
"reolink": {
"editor_label": "",
"media_resolution": {
"editor_label": "",
"high": "",
"low": ""
},
"url": ""
},
"title": "Títol d'aquesta càmera (detecció automàtica de l'entitat)",
"triggers": {
"editor_label": "Opcions d'activació",
@@ -574,7 +607,9 @@
},
"error": {
"awaiting_live": "S'està esperant que es carregui la transmissió en directe ...",
"awaiting_media": "",
"camera_initialization": "",
"camera_initialization_reolink": "",
"could_not_render_elements": "No s'han pogut representar els elements de la imatge",
"could_not_resolve": "No s'ha pogut resoldre l'URL multimèdia",
"diagnostics": "Diagnòstic de targetes. Reviseu la informació confidencial abans de compartir-la",
+1
View File
@@ -607,6 +607,7 @@
},
"error": {
"awaiting_live": "Waiting for live stream to load ...",
"awaiting_media": "Waiting for media to load",
"camera_initialization": "Camera initialization failed",
"camera_initialization_reolink": "Could not initialize Reolink camera",
"could_not_render_elements": "Could not render picture elements",
+35
View File
@@ -127,6 +127,39 @@
},
"url": "URL de l’interface MotionEye"
},
"proxy": {
"dynamic": "",
"editor_label": "",
"media": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
},
"ssl_ciphers": {
"auto": "",
"default": "",
"editor_label": "",
"insecure": "",
"intermediate": "",
"modern": ""
},
"ssl_verification": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
}
},
"reolink": {
"editor_label": "",
"media_resolution": {
"editor_label": "",
"high": "",
"low": ""
},
"url": ""
},
"title": "Titre de cette caméra (détection automatique à partir de l'entité)",
"triggers": {
"editor_label": "Modèle de répertoire des films",
@@ -574,7 +607,9 @@
},
"error": {
"awaiting_live": "En attente du flux en direct...",
"awaiting_media": "",
"camera_initialization": "",
"camera_initialization_reolink": "",
"could_not_render_elements": "Impossible de restituer les éléments de l'image",
"could_not_resolve": "Impossible de résoudre l'URL du média",
"diagnostics": "Diagnostic de la carte. Veuillez enlever les informations confidentielles avant de les partager",
+35
View File
@@ -127,6 +127,39 @@
},
"url": "URL dell'interfaccia utente di MotionEye"
},
"proxy": {
"dynamic": "",
"editor_label": "",
"media": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
},
"ssl_ciphers": {
"auto": "",
"default": "",
"editor_label": "",
"insecure": "",
"intermediate": "",
"modern": ""
},
"ssl_verification": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
}
},
"reolink": {
"editor_label": "",
"media_resolution": {
"editor_label": "",
"high": "",
"low": ""
},
"url": ""
},
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
"triggers": {
"editor_label": "Trigger Opzioni",
@@ -574,7 +607,9 @@
},
"error": {
"awaiting_live": "",
"awaiting_media": "",
"camera_initialization": "",
"camera_initialization_reolink": "",
"could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
"could_not_resolve": "Impossibile risolvere l'URL dei media",
"diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere",
+35
View File
@@ -127,6 +127,39 @@
},
"url": "URL da interface de usuário do MotionEye"
},
"proxy": {
"dynamic": "",
"editor_label": "",
"media": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
},
"ssl_ciphers": {
"auto": "",
"default": "",
"editor_label": "",
"insecure": "",
"intermediate": "",
"modern": ""
},
"ssl_verification": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
}
},
"reolink": {
"editor_label": "",
"media_resolution": {
"editor_label": "",
"high": "",
"low": ""
},
"url": ""
},
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
"triggers": {
"editor_label": "Opções de acionamento",
@@ -574,7 +607,9 @@
},
"error": {
"awaiting_live": "",
"awaiting_media": "",
"camera_initialization": "",
"camera_initialization_reolink": "",
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
"could_not_resolve": "Não foi possível resolver o URL de mídia",
"diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar",
+35
View File
@@ -127,6 +127,39 @@
},
"url": "Link"
},
"proxy": {
"dynamic": "",
"editor_label": "",
"media": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
},
"ssl_ciphers": {
"auto": "",
"default": "",
"editor_label": "",
"insecure": "",
"intermediate": "",
"modern": ""
},
"ssl_verification": {
"auto": "",
"editor_label": "",
"false": "",
"true": ""
}
},
"reolink": {
"editor_label": "",
"media_resolution": {
"editor_label": "",
"high": "",
"low": ""
},
"url": ""
},
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
"triggers": {
"editor_label": "Opções de activação",
@@ -574,7 +607,9 @@
},
"error": {
"awaiting_live": "",
"awaiting_media": "",
"camera_initialization": "",
"camera_initialization_reolink": "",
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
"could_not_resolve": "Não foi possível resolver o URL de mídia",
"diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar",
+1 -1
View File
@@ -57,7 +57,7 @@ export interface Diagnostics {
};
}
export const getIntegrationDiagnostics = async (
const getIntegrationDiagnostics = async (
integration: string,
hass?: HomeAssistant,
): Promise<IntegrationDiagnostics> => {
+8
View File
@@ -4,6 +4,14 @@ import { FrigateCardView, ViewDisplayMode } from '../config/types.js';
import { MediaQueries } from './media-queries';
import { MediaQueriesResults } from './media-queries-results';
declare module 'view' {
interface ViewContext {
loading?: {
query?: unknown;
};
}
}
interface ViewEvolveParameters {
view?: FrigateCardView;
camera?: string;
+28 -496
View File
@@ -1,15 +1,7 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { QueryType } from '../../../src/camera-manager/types';
import { describe, expect, it, vi } from 'vitest';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ViewModifier } from '../../../src/card-controller/view/types';
import { FrigateCardView, ViewDisplayMode } from '../../../src/config/types';
import {
EventMediaQueries,
RecordingMediaQueries,
} from '../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
import { View } from '../../../src/view/view';
import {
createCameraManager,
@@ -329,496 +321,36 @@ describe('getViewByParameters', () => {
});
describe('should get correct default display mode', () => {
describe.each([['single' as const], ['grid' as const]])(
'%s',
(displayMode: ViewDisplayMode) => {
it.each([
['media' as const],
['clip' as const],
['recording' as const],
['snapshot' as const],
['live' as const],
])('%s', (viewName: FrigateCardView) => {
const api = createPopulatedAPI({
media_viewer: {
display: {
mode: displayMode,
},
},
live: {
display: {
mode: displayMode,
},
},
});
const factory = new ViewFactory(api);
expect(
factory.getViewByParameters({
params: {
view: viewName,
},
})?.displayMode,
).toBe(displayMode);
});
},
);
});
});
describe('getViewByParametersWithNewQuery', () => {
it('should not execute query without config', async () => {
const factory = new ViewFactory(createCardAPI());
expect(await factory.getViewByParametersWithNewQuery()).toBeNull();
});
describe('with a live view', () => {
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-07-21T13:22:06Z'));
});
afterAll(() => {
vi.useRealTimers();
});
describe('should set timeline window', async () => {
it('should set timeline to now for live views', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.context).toEqual({
timeline: {
window: {
start: new Date('2024-07-21T12:22:06.000Z'),
end: new Date('2024-07-21T13:22:06.000Z'),
},
},
});
});
it('should unset timeline for non-live views', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
baseView: createView({
context: {
timeline: {
window: {
start: new Date('2024-07-21T12:22:06.000Z'),
end: new Date('2024-07-21T13:22:06.000Z'),
},
},
},
}),
params: {
view: 'clip',
},
});
expect(view?.context).toEqual({ timeline: {} });
});
});
it('should not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
mode: 'none' as const,
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'all',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch recordings', async () => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
media_type: 'recordings',
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
});
});
describe('with a media view', () => {
it('should do nothing with same camera', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'media',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch clips with different camera', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'media',
camera: 'camera.kitchen',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.kitchen',
eventsMediaType: 'clips',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('with an events-based view', () => {
it.each([
['clip' as const, 'clips' as const],
['clips' as const, 'clips' as const],
['snapshot' as const, 'snapshots' as const],
['snapshots' as const, 'snapshots' as const],
])(
'%s',
async (viewName: FrigateCardView, eventsMediaType: 'clips' | 'snapshots') => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: viewName,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: eventsMediaType,
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
},
);
});
describe('with an recordings-based view', () => {
it.each([['recording' as const], ['recordings' as const]])(
'%s',
async (viewName: FrigateCardView) => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: viewName,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
},
);
});
describe('with an media viewer view', () => {
it('hould not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(
createPopulatedAPI({
describe.each([
['single' as const, { mode: 'single' as const }],
['grid' as const, { mode: 'grid' as const }],
['single' as const, undefined],
])('%s', (expectedDisplayMode: ViewDisplayMode, displayConfig?: unknown) => {
it.each([
['media' as const],
['clip' as const],
['recording' as const],
['snapshot' as const],
['live' as const],
])('%s', (viewName: FrigateCardView) => {
const api = createPopulatedAPI({
media_viewer: {
controls: {
thumbnails: {
mode: 'none' as const,
},
display: displayConfig,
},
live: {
display: displayConfig,
},
});
const factory = new ViewFactory(api);
expect(
factory.getViewByParameters({
params: {
view: viewName,
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'clip',
},
})?.displayMode,
).toBe(expectedDisplayMode);
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('when changing to gallery from the media viewer', () => {
it('should adopt query and results', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new MediaQueriesResults(),
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'clips',
},
});
expect(view?.query).toBe(baseView.query);
expect(view?.queryResults).toBe(baseView.queryResults);
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('when set or remove seek time', () => {
it('should set seek time when results are selected based on time', async () => {
const now = new Date();
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'clips',
},
queryExecutorOptions: {
selectResult: {
time: {
time: now,
},
},
},
});
expect(view?.context).toEqual({
mediaViewer: {
seek: now,
},
});
});
it('should remove seek time when results are not selected based on time', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
baseView: new View({
view: 'clips',
camera: 'camera.office',
context: {
mediaViewer: {
seek: new Date(),
},
},
}),
params: {
view: 'clips',
},
});
expect(view?.context?.mediaViewer?.seek).toBeUndefined();
});
});
});
describe('getViewDefaultWithNewQuery', () => {
it('should fetch events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewDefaultWithNewQuery();
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'all',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('getViewByParametersWithExistingQuery', () => {
it('should not execute anything when query is absent', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithExistingQuery({
params: {
view: 'live',
camera: 'camera.office',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultRecordingQuery).not.toBeCalled();
});
it('should set query results', async () => {
const executor = mock<QueryExecutor>();
const queryResults = new MediaQueriesResults();
executor.execute.mockResolvedValue(queryResults);
const factory = new ViewFactory(createPopulatedAPI(), executor);
const query = new RecordingMediaQueries();
const view = await factory.getViewByParametersWithExistingQuery({
params: {
view: 'live',
camera: 'camera.office',
query: query,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
});
});
@@ -0,0 +1,32 @@
import { expect, it } from 'vitest';
import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers';
import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context';
import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
import { EventMediaQueries } from '../../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../../src/view/media-queries-results';
import { createView } from '../../../test-utils';
it('should apply view modifiers', () => {
const view = createView();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const context = {
timeline: { window: { start: new Date(), end: new Date() } },
};
const modifiers = [
new SetQueryViewModifier({
query: query,
queryResults: queryResults,
}),
new MergeContextViewModifier(context),
];
applyViewModifiers(view, modifiers);
expect(view.query).toBe(query);
expect(view.queryResults).toBe(queryResults);
expect(view.context).toEqual(context);
});
@@ -0,0 +1,30 @@
import { expect, it } from 'vitest';
import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
import { createView } from '../../../test-utils';
import { EventMediaQueries } from '../../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../../src/view/media-queries-results';
it('should do nothing without arguments', () => {
const view = createView();
const modifier = new SetQueryViewModifier();
modifier.modify(view);
expect(view.query).toBeNull();
expect(view.queryResults).toBeNull();
});
it('should set query and results', () => {
const view = createView();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const modifier = new SetQueryViewModifier({
query: query,
queryResults: queryResults,
});
modifier.modify(view);
expect(view.query).toBe(query);
expect(view.queryResults).toBe(queryResults);
});
+381 -54
View File
@@ -13,6 +13,15 @@ import {
createStore,
createView,
} from '../../test-utils';
import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
import { EventMediaQueries } from '../../../src/view/media-queries';
import { QueryType } from '../../../src/camera-manager/types';
import { SetQueryViewModifier } from '../../../src/card-controller/view/modifiers/set-query';
import { View } from '../../../src/view/view';
import {
QueryExecutorOptions,
ViewModifier,
} from '../../../src/card-controller/view/types';
describe('should act correctly when view is set', () => {
it('basic view', () => {
@@ -26,7 +35,7 @@ describe('should act correctly when view is set', () => {
factory.getViewDefault.mockReturnValue(view);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
@@ -53,7 +62,7 @@ describe('should act correctly when view is set', () => {
factory.getViewDefault.mockReturnValue(view_1);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
@@ -82,7 +91,7 @@ it('setViewWithMergedContext', () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, factory);
const manager = new ViewManager(api, { viewFactory: factory });
const context: ViewContext = { timeline: {} };
// Setting context with no existing view does nothing.
@@ -104,14 +113,14 @@ it('setViewWithMergedContext', () => {
it('getEpoch', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
expect(manager.getEpoch()).toBeTruthy();
expect(manager.getEpoch().manager).toBe(manager);
});
it('reset', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.reset();
expect(manager.getView()).toBeNull();
@@ -133,7 +142,7 @@ it('setViewDefault', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(manager.getView()?.view).toBe('live');
@@ -144,7 +153,7 @@ it('setViewByParameters', () => {
const factory = mock<ViewFactory>();
factory.getViewByParameters.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewByParameters();
expect(manager.getView()?.view).toBe('live');
@@ -152,10 +161,16 @@ it('setViewByParameters', () => {
});
it('setViewDefaultWithNewQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewDefaultWithNewQuery.mockResolvedValue(createView());
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.view).toBe('live');
@@ -163,10 +178,16 @@ it('setViewDefaultWithNewQuery', async () => {
});
it('setViewByParametersWithNewQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewByParametersWithNewQuery.mockResolvedValue(createView());
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewByParametersWithNewQuery();
expect(manager.getView()?.view).toBe('live');
@@ -174,10 +195,17 @@ it('setViewByParametersWithNewQuery', async () => {
});
it('setViewByParametersWithExistingQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewByParametersWithExistingQuery.mockResolvedValue(createView());
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
const manager = new ViewManager(createCardAPI(), factory);
await manager.setViewByParametersWithExistingQuery();
expect(manager.getView()?.view).toBe('live');
@@ -185,33 +213,57 @@ it('setViewByParametersWithExistingQuery', async () => {
});
describe('should handle exceptions', () => {
it('non-async', () => {
const factory = mock<ViewFactory>();
it('should handle exceptions in sync calls', () => {
const error = new Error();
factory.getViewDefault.mockImplementation(() => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockImplementation(() => {
throw error;
});
const api = createCardAPI();
const manager = new ViewManager(api, factory);
const manager = new ViewManager(api, { viewFactory: viewFactory });
manager.setViewDefault();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
it('async', async () => {
const factory = mock<ViewFactory>();
it('should handle viewFactory exceptions in async calls', async () => {
const error = new Error();
factory.getViewByParametersWithNewQuery.mockRejectedValue(error);
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockImplementation(() => {
throw error;
});
const api = createCardAPI();
const manager = new ViewManager(api, factory);
await manager.setViewByParametersWithNewQuery();
const manager = new ViewManager(api, { viewFactory: viewFactory });
await manager.setViewDefaultWithNewQuery();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
it('should handle viewQueryExecutor exceptions in async calls', async () => {
const error = new Error();
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockRejectedValue(error);
const api = createCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
// The initial view will have been set.
expect(manager.hasView()).toBeTruthy();
// But an error will also be generated.
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
});
describe('isViewSupportedByCamera', () => {
@@ -265,7 +317,7 @@ describe('hasMajorMediaChange', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ view: 'clips' }))).toBeTruthy();
@@ -275,7 +327,7 @@ describe('hasMajorMediaChange', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ camera: 'camera-1' }));
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ camera: 'camera-2' }))).toBeTruthy();
@@ -293,7 +345,7 @@ describe('hasMajorMediaChange', () => {
createView({ context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(
@@ -315,7 +367,7 @@ describe('hasMajorMediaChange', () => {
createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(
@@ -335,7 +387,7 @@ describe('hasMajorMediaChange', () => {
createView({ view: 'media', queryResults: queryResults_1 }),
);
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(
@@ -353,42 +405,317 @@ describe('hasMajorMediaChange', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 }));
const manager = new ViewManager(createCardAPI(), factory);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
).toBeFalsy();
});
});
describe('should initialize', () => {
it('without querystring', async () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, factory);
const view = createView({
view: 'live',
camera: 'camera',
});
factory.getViewDefaultWithNewQuery.mockResolvedValue(view);
expect(await manager.initialize()).toBeTruthy();
expect(manager.getView()).toBe(view);
describe('should initialize', () => {
it('without querystring', async () => {
const view = createView({
view: 'live',
camera: 'camera',
});
it('with querystring', async () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, factory);
vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
true,
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(view);
const api = createCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
});
expect(await manager.initialize()).toBeTruthy();
expect(manager.getView()).toBe(view);
});
it('with querystring', async () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, { viewFactory: factory });
vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
true,
);
expect(await manager.initialize()).toBeTruthy();
expect(manager.hasView()).toBeFalsy();
});
});
it('should adopt query and results when changing to gallery from viewer', async () => {
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new MediaQueriesResults(),
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(baseView)
.mockReturnValueOnce(createView({ view: 'clips' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
manager.setViewDefault();
expect(manager.getView()?.is('media')).toBeTruthy();
await manager.setViewDefaultWithNewQuery({
params: {
view: 'clips',
},
});
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBe(baseView.query);
expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
});
describe('should apply async view modifications', () => {
it('should apply modifications successfully', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([
new SetQueryViewModifier({
query: query,
queryResults: queryResults,
}),
]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.query).toBe(query);
expect(manager.getView()?.queryResults).toBe(queryResults);
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
it('should not apply modifications if there is a major media change', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 1 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [
new SetQueryViewModifier({
query: query,
queryResults: queryResults,
}),
];
},
);
await manager.setViewDefaultWithNewQuery();
// View set during the async operation should not be touched.
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBeNull();
expect(manager.getView()?.queryResults).toBeNull();
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
describe('should manage loading state correctly', () => {
it('should mark as not loading when with major media change', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 1 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
expect(await manager.initialize()).toBeTruthy();
await manager.setViewDefaultWithNewQuery();
expect(manager.hasView()).toBeFalsy();
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
it('should not change loading status if something else is being loaded', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 2 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBe(2);
});
it('should not change loading status if it is unexpected', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate another view update (without a major media change)
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ context: { loading: { query: 100 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBe(100);
});
});
});
it('should adopt query and results when changing to gallery from viewer', async () => {
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new MediaQueriesResults(),
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(baseView)
.mockReturnValueOnce(createView({ view: 'clips' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
manager.setViewDefault();
expect(manager.getView()?.is('media')).toBeTruthy();
await manager.setViewDefaultWithNewQuery({
params: {
view: 'clips',
},
});
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBe(baseView.query);
expect(manager.getView()?.queryResults).toBe(baseView.queryResults);
expect(viewQueryExecutor.getNewQueryModifiers).not.toHaveBeenCalled();
expect(viewQueryExecutor.getExistingQueryModifiers).not.toHaveBeenCalled();
});
@@ -0,0 +1,432 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { applyViewModifiers } from '../../../src/card-controller/view/modifiers';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
import { FrigateCardView } from '../../../src/config/types';
import {
EventMediaQueries,
RecordingMediaQueries,
} from '../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
import { View } from '../../../src/view/view';
import { createCardAPI, createView } from '../../test-utils';
import { createPopulatedAPI } from './test-utils';
describe('ViewQueryExecutor', () => {
describe('getExistingQueryModifiers', () => {
it('should return modifier with result when query present', async () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(createCardAPI(), executor);
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const view = createView({
query: query,
});
executor.execute.mockResolvedValue(queryResults);
const queryExecutorOptions = {};
const modifiers = await viewQueryExecutor.getExistingQueryModifiers(
view,
queryExecutorOptions,
);
applyViewModifiers(view, modifiers);
expect(view.query).toBe(query);
expect(view.queryResults).toBe(queryResults);
expect(executor.execute).toBeCalledWith(query, queryExecutorOptions);
});
it('should not return modifier when query absent', async () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(createCardAPI(), executor);
const view = createView();
const queryExecutorOptions = {};
const modifiers = await viewQueryExecutor.getExistingQueryModifiers(
view,
queryExecutorOptions,
);
expect(modifiers?.length).toBe(0);
applyViewModifiers(view, modifiers);
expect(view.query).toBeNull();
expect(view.queryResults).toBeNull();
expect(executor.execute).not.toBeCalled();
});
});
describe('getNewQueryModifiers', () => {
it('should return null without config', async () => {
const factory = new ViewQueryExecutor(createCardAPI());
expect(await factory.getNewQueryModifiers(createView())).toBeNull();
});
describe('with a live view', () => {
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-07-21T13:22:06Z'));
});
afterAll(() => {
vi.useRealTimers();
});
it('should set query and queryResults for events', async () => {
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const executor = mock<QueryExecutor>();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
const view = createView({ view: 'live', camera: 'camera.office' });
const queryExecutorOptions = {};
const modifiers = await viewQueryExecutor.getNewQueryModifiers(
view,
queryExecutorOptions,
);
applyViewModifiers(view, modifiers);
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'all',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should set query and queryResults for recordings', async () => {
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
const executor = mock<QueryExecutor>();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const viewQueryExecutor = new ViewQueryExecutor(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
media_type: 'recordings',
},
},
},
}),
executor,
);
const view = createView({ view: 'live', camera: 'camera.office' });
const queryExecutorOptions = {};
const modifiers = await viewQueryExecutor.getNewQueryModifiers(
view,
queryExecutorOptions,
);
applyViewModifiers(view, modifiers);
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultEventQuery).not.toBeCalled();
});
describe('should set timeline window', async () => {
it('should set timeline to now for live views', async () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(
createPopulatedAPI(),
executor,
);
const view = createView({ view: 'live', camera: 'camera.office' });
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.context).toEqual({
timeline: {
window: {
start: new Date('2024-07-21T12:22:06.000Z'),
end: new Date('2024-07-21T13:22:06.000Z'),
},
},
});
});
it('should unset timeline for non-live views', async () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(
createPopulatedAPI(),
executor,
);
const view = createView({
view: 'clips',
camera: 'camera.office',
context: {
timeline: {
window: {
start: new Date('2024-07-21T12:22:06.000Z'),
end: new Date('2024-07-21T13:22:06.000Z'),
},
},
},
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.context).toEqual({ timeline: {} });
});
});
it('should not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
mode: 'none' as const,
},
},
},
}),
executor,
);
const view = createView({
view: 'live',
camera: 'camera.office',
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('with a media view', () => {
it('should set query and queryResults for events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
const view = new View({
view: 'media',
camera: 'camera.office',
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'clips',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('with an events-based view', () => {
it.each([
['clip' as const, 'clips' as const],
['clips' as const, 'clips' as const],
['snapshot' as const, 'snapshots' as const],
['snapshots' as const, 'snapshots' as const],
])(
'%s',
async (viewName: FrigateCardView, eventsMediaType: 'clips' | 'snapshots') => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const viewQueryExecutor = new ViewQueryExecutor(
createPopulatedAPI(),
executor,
);
const view = new View({
view: viewName,
camera: 'camera.office',
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: eventsMediaType,
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
},
);
});
describe('with a recordings-based view', () => {
it.each([['recording' as const], ['recordings' as const]])(
'%s',
async (viewName: FrigateCardView) => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const viewQueryExecutor = new ViewQueryExecutor(
createPopulatedAPI(),
executor,
);
const view = new View({
view: viewName,
camera: 'camera.office',
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
},
);
});
describe('with an media viewer view', () => {
it('should not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(
createPopulatedAPI({
media_viewer: {
controls: {
thumbnails: {
mode: 'none' as const,
},
},
},
}),
executor,
);
const view = new View({
view: 'clip',
camera: 'camera.office',
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('when setting or removing seek time', () => {
it('should set seek time when results are selected based on time', async () => {
const now = new Date();
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
const view = new View({
view: 'clip',
camera: 'camera.office',
});
const queryExecutorOptions = {
selectResult: {
time: {
time: now,
},
},
};
const modifiers = await viewQueryExecutor.getNewQueryModifiers(
view,
queryExecutorOptions,
);
applyViewModifiers(view, modifiers);
expect(view?.context).toEqual({
mediaViewer: {
seek: now,
},
});
});
it('should remove seek time when results are not selected based on time', async () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
const view = new View({
view: 'clip',
camera: 'camera.office',
context: {
mediaViewer: {
seek: new Date(),
},
},
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.context?.mediaViewer?.seek).toBeUndefined();
});
});
});
});