diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index bbd303fe..89b0e8f5 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -691,6 +691,8 @@ export class CameraManager { _queries, ', Results:', results, + ', Options:', + engineOptions ?? {}, ']', ); return results; diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts index 69a8fadc..db6c5693 100644 --- a/src/card-controller/view/factory.ts +++ b/src/card-controller/view/factory.ts @@ -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 { - return this._executeNewQuery(this.getViewDefault(options), { - ...options, - queryExecutorOptions: { - useCache: false, - ...options?.queryExecutorOptions, - }, - }); - } - - public async getViewByParametersWithNewQuery( - options?: ViewFactoryOptions, - ): Promise { - return this._executeNewQuery(this.getViewByParameters(options), { - ...options, - queryExecutorOptions: { - useCache: false, - ...options?.queryExecutorOptions, - }, - }); - } - - public async getViewByParametersWithExistingQuery( - options?: ViewFactoryOptions, - ): Promise { - 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 { - 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 => { - /* 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 { - 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'; diff --git a/src/card-controller/view/modifiers/index.ts b/src/card-controller/view/modifiers/index.ts new file mode 100644 index 00000000..cba86f3d --- /dev/null +++ b/src/card-controller/view/modifiers/index.ts @@ -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)); +}; diff --git a/src/card-controller/view/modifiers/set-query.ts b/src/card-controller/view/modifiers/set-query.ts new file mode 100644 index 00000000..8057c2fd --- /dev/null +++ b/src/card-controller/view/modifiers/set-query.ts @@ -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; + } + } +} diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts index 8e3df7e1..08cab553 100644 --- a/src/card-controller/view/query-executor.ts +++ b/src/card-controller/view/query-executor.ts @@ -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 { + }): Promise { const capabilitySearch: CapabilitySearchOptions = !options?.eventsMediaType || options?.eventsMediaType === 'all' ? { @@ -61,7 +61,7 @@ export class QueryExecutor { public async executeDefaultRecordingQuery(options?: { cameraID?: string; executorOptions?: QueryExecutorOptions; - }): Promise { + }): Promise { const cameraManager = this._api.getCameraManager(); const cameraIDs = options?.cameraID ? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings') diff --git a/src/card-controller/view/types.ts b/src/card-controller/view/types.ts index a1aae634..5b769935 100644 --- a/src/card-controller/view/types.ts +++ b/src/card-controller/view/types.ts @@ -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 {} diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index 7b40de0e..e63512db 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -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 => - 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 => - 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 => - 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, + 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, options?: ViewFactoryOptions, ): Promise { - 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 | null): void { const oldView = this._view; log( diff --git a/src/card-controller/view/view-query-executor.ts b/src/card-controller/view/view-query-executor.ts new file mode 100644 index 00000000..942caa41 --- /dev/null +++ b/src/card-controller/view/view-query-executor.ts @@ -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 { + return view.query + ? [ + new SetQueryViewModifier({ + queryResults: await this._executor.execute(view.query, queryExecutorOptions), + }), + ] + : []; + } + + public async getNewQueryModifiers( + view: View, + queryExecutorOptions?: QueryExecutorOptions, + ): Promise { + return await this._executeNewQuery(view, { + useCache: false, + ...queryExecutorOptions, + }); + } + + protected async _executeNewQuery( + view: View, + queryExecutorOptions?: QueryExecutorOptions, + ): Promise { + 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 => { + /* 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')]; + } + } +} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 3a32ff9d..36da8b25 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -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, }); } diff --git a/src/components/viewer/index.ts b/src/components/viewer/index.ts index 1f0df565..5c10ff30 100644 --- a/src/components/viewer/index.ts +++ b/src/components/viewer/index.ts @@ -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, }); } diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 743d63c4..5dce88f6 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -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", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 789dcc7f..0c52e71f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index f990fd57..8aa41db8 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -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", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index b9446c32..a455579a 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -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", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index e820e875..7bb4b6b3 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -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", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 6306ff65..f11e7e5e 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -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", diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index b8483a8b..5b6c4e46 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -57,7 +57,7 @@ export interface Diagnostics { }; } -export const getIntegrationDiagnostics = async ( +const getIntegrationDiagnostics = async ( integration: string, hass?: HomeAssistant, ): Promise => { diff --git a/src/view/view.ts b/src/view/view.ts index 8ba9d28e..36c12024 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -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; diff --git a/tests/card-controller/view/factory.test.ts b/tests/card-controller/view/factory.test.ts index 6a293be7..c1c770af 100644 --- a/tests/card-controller/view/factory.test.ts +++ b/tests/card-controller/view/factory.test.ts @@ -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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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); - }); -}); diff --git a/tests/card-controller/view/modifiers/index.test.ts b/tests/card-controller/view/modifiers/index.test.ts new file mode 100644 index 00000000..d5f38a13 --- /dev/null +++ b/tests/card-controller/view/modifiers/index.test.ts @@ -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); +}); diff --git a/tests/card-controller/view/modifiers/set-query.test.ts b/tests/card-controller/view/modifiers/set-query.test.ts new file mode 100644 index 00000000..85bfeb2e --- /dev/null +++ b/tests/card-controller/view/modifiers/set-query.test.ts @@ -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); +}); diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index 809e0d6a..0cb2461c 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -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(); - 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(); - 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(); - 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(); 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(); 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(); - factory.getViewDefaultWithNewQuery.mockResolvedValue(createView()); + const viewFactory = mock(); + viewFactory.getViewDefault.mockReturnValue(createView()); - const manager = new ViewManager(createCardAPI(), factory); + const viewQueryExecutor = mock(); + 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(); - factory.getViewByParametersWithNewQuery.mockResolvedValue(createView()); + const viewFactory = mock(); + viewFactory.getViewByParameters.mockReturnValue(createView()); - const manager = new ViewManager(createCardAPI(), factory); + const viewQueryExecutor = mock(); + 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(); - factory.getViewByParametersWithExistingQuery.mockResolvedValue(createView()); + const viewFactory = mock(); + viewFactory.getViewByParameters.mockReturnValue(createView()); + + const viewQueryExecutor = mock(); + 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(); + it('should handle exceptions in sync calls', () => { const error = new Error(); - factory.getViewDefault.mockImplementation(() => { + const viewFactory = mock(); + 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(); + it('should handle viewFactory exceptions in async calls', async () => { const error = new Error(); - factory.getViewByParametersWithNewQuery.mockRejectedValue(error); + const viewFactory = mock(); + 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.getViewDefault.mockReturnValue(createView()); + const viewQueryExecutor = mock(); + 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(); 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(); 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(); 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(); - 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(); - const manager = new ViewManager(api, factory); - vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue( - true, + const viewFactory = mock(); + 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(); + 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.getViewDefault + .mockReturnValueOnce(baseView) + .mockReturnValueOnce(createView({ view: 'clips' })); + + const viewQueryExecutor = mock(); + 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.getViewDefault.mockReturnValue(createView({ view: 'live' })); + + const query = new EventMediaQueries(); + const queryResults = new MediaQueriesResults(); + + const viewQueryExecutor = mock(); + 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); + + const query = new EventMediaQueries(); + const queryResults = new MediaQueriesResults(); + + const viewQueryExecutor = mock(); + + 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 => { + // 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); + const viewQueryExecutor = mock(); + + 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 => { + 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); + const viewQueryExecutor = mock(); + + 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 => { + 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.getViewDefault.mockReturnValueOnce(createView({ view: 'live' })); + const viewQueryExecutor = mock(); + + 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 => { + 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.getViewDefault + .mockReturnValueOnce(baseView) + .mockReturnValueOnce(createView({ view: 'clips' })); + + const viewQueryExecutor = mock(); + 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(); +}); diff --git a/tests/card-controller/view/view-query-executor.test.ts b/tests/card-controller/view/view-query-executor.test.ts new file mode 100644 index 00000000..33cf150f --- /dev/null +++ b/tests/card-controller/view/view-query-executor.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + 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(); + }); + }); + }); +});