diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 60ba7fac..1a9f8ae4 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -561,11 +561,14 @@ export class CameraManager { } public areMediaQueriesResultsFresh( - queries: T[], resultsTimestamp: Date, + queries: T[] | null, ): boolean { - const now = new Date(); + if (!queries) { + return false; + } + const now = new Date(); for (const query of queries) { const engines = this._store.getEnginesForCameraIDs(query.cameraIDs); for (const [engine, cameraIDs] of engines ?? []) { diff --git a/src/card-controller/folders/ha/metadata-generator.ts b/src/card-controller/folders/ha/metadata-generator.ts index e3ed0e7e..3d8ee4f9 100644 --- a/src/card-controller/folders/ha/metadata-generator.ts +++ b/src/card-controller/folders/ha/metadata-generator.ts @@ -6,7 +6,7 @@ import { RichBrowseMedia, } from '../../../ha/browse-media/types'; -import parser from 'any-date-parser'; +import type parser from 'any-date-parser'; import { isValidDate } from '../../../utils/basic'; import { regexpExtract } from '../../../utils/regexp-extract'; import { REGEXP_GROUP_VALUE_KEY } from './types'; diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts index 27ee911a..c2935d80 100644 --- a/src/card-controller/folders/types.ts +++ b/src/card-controller/folders/types.ts @@ -22,10 +22,13 @@ export class FolderInitializationError extends AdvancedCameraCardError {} // Folder Query // ============ -export type FolderPathComponent = { - folder?: ViewFolder; +interface FolderPathComponentMetadata { ha?: HAFolderPathComponent; -}; +} + +export interface FolderPathComponent extends FolderPathComponentMetadata { + folder?: ViewFolder; +} export interface FolderQuery { folder: FolderConfig; diff --git a/src/card-controller/view/factory.ts b/src/card-controller/view/factory.ts index e6cff33b..fe5c51e3 100644 --- a/src/card-controller/view/factory.ts +++ b/src/card-controller/view/factory.ts @@ -6,7 +6,6 @@ import { View, ViewParameters } from '../../view/view'; import { getCameraIDsForViewName, isViewSupportedByCamera, - isViewSupportedByQueryOnly, } from '../../view/view-support'; import { CardViewAPI } from '../types'; import { applyViewModifiers } from './modifiers'; @@ -36,7 +35,11 @@ export class ViewFactory { cameraID = options.params.camera; } else { const cameraIDs = [ - ...getCameraIDsForViewName(viewName, this._api.getCameraManager()), + ...getCameraIDsForViewName( + viewName, + this._api.getCameraManager(), + this._api.getFoldersManager(), + ), ]; if ( @@ -79,10 +82,11 @@ export class ViewFactory { const viewCameraIDs = getCameraIDsForViewName( viewName, this._api.getCameraManager(), + this._api.getFoldersManager(), ); // Reset to the default camera. - cameraID = viewCameraIDs.keys().next().value ?? null; + cameraID = viewCameraIDs?.keys().next().value ?? null; } if (!cameraID) { @@ -103,16 +107,21 @@ export class ViewFactory { } if ( - !isViewSupportedByCamera(viewName, this._api.getCameraManager(), cameraID) && - !isViewSupportedByQueryOnly( + !isViewSupportedByCamera( viewName, - options?.params?.query ?? options?.baseView?.query, - options?.params?.queryResults ?? options?.baseView?.queryResults, + this._api.getCameraManager(), + this._api.getFoldersManager(), + cameraID, ) ) { if ( options?.failSafe && - isViewSupportedByCamera(VIEW_DEFAULT, this._api.getCameraManager(), cameraID) + isViewSupportedByCamera( + VIEW_DEFAULT, + this._api.getCameraManager(), + this._api.getFoldersManager(), + cameraID, + ) ) { viewName = VIEW_DEFAULT; } else { diff --git a/src/card.ts b/src/card.ts index d9c44625..4c111cbe 100644 --- a/src/card.ts +++ b/src/card.ts @@ -255,9 +255,9 @@ class AdvancedCameraCard extends LitElement { this._hass, this._config, this._controller.getCameraManager(), + this._controller.getFoldersManager(), { currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(), - foldersManager: this._controller.getFoldersManager(), fullscreenManager: this._controller.getFullscreenManager(), inExpandedMode: this._controller.getExpandManager().isExpanded(), mediaPlayerController: this._controller.getMediaPlayerManager(), @@ -381,6 +381,7 @@ class AdvancedCameraCard extends LitElement { .hass=${this._hass} .viewManagerEpoch=${this._controller.getViewManager().getEpoch()} .cameraManager=${cameraManager} + .foldersManager=${this._controller.getFoldersManager()} .viewItemManager=${this._controller.getViewItemManager()} .resolvedMediaCache=${this._controller.getResolvedMediaCache()} .config=${this._controller.getConfigManager().getConfig()} diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 1205eca5..f3224bd9 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -29,16 +29,11 @@ import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { ViewItemClassifier } from '../view/item-classifier'; import { QueryClassifier } from '../view/query-classifier'; import { View } from '../view/view'; -import { - getCameraIDsForViewName, - isViewSupportedByCamera, - isViewSupportedByQueryOnly, -} from '../view/view-support'; +import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support'; export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; showCameraUIButton?: boolean; - foldersManager?: FoldersManager | null; fullscreenManager?: FullscreenManager | null; inExpandedMode?: boolean; microphoneManager?: MicrophoneManager | null; @@ -71,18 +66,19 @@ export class MenuButtonController { hass: HomeAssistant, config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, options?: MenuButtonControllerOptions, ): MenuItem[] { return [ this._getIrisButton(config), this._getCamerasButton(config, cameraManager, options?.view), this._getSubstreamsButton(config, cameraManager, options?.view), - this._getLiveButton(config, cameraManager, options?.view), - this._getClipsButton(config, cameraManager, options?.view), - this._getSnapshotsButton(config, cameraManager, options?.view), - this._getRecordingsButton(config, cameraManager, options?.view), - this._getImageButton(config, cameraManager, options?.view), - this._getTimelineButton(config, cameraManager, options?.view), + this._getLiveButton(config, cameraManager, foldersManager, options?.view), + this._getClipsButton(config, cameraManager, foldersManager, options?.view), + this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view), + this._getRecordingsButton(config, cameraManager, foldersManager, options?.view), + this._getImageButton(config, cameraManager, foldersManager, options?.view), + this._getTimelineButton(config, cameraManager, foldersManager, options?.view), this._getDownloadButton(config, cameraManager, options?.view), this._getCameraUIButton(config, options?.showCameraUIButton), this._getMicrophoneButton( @@ -102,10 +98,10 @@ export class MenuButtonController { this._getPlayPauseButton(config, options?.currentMediaLoadedInfo), this._getMuteUnmuteButton(config, options?.currentMediaLoadedInfo), this._getScreenshotButton(config, options?.currentMediaLoadedInfo), - this._getDisplayModeButton(config, cameraManager, options?.view), + this._getDisplayModeButton(config, cameraManager, foldersManager, options?.view), this._getPTZControlsButton(config, cameraManager, options?.view), this._getPTZHomeButton(config, cameraManager, options?.view), - this._getFoldersButton(config, options?.foldersManager, options?.view), + this._getFoldersButton(config, foldersManager, options?.view), ...this._dynamicMenuButtons.map((button) => ({ style: this._getStyleFromActions(config, button, options), @@ -232,9 +228,11 @@ export class MenuButtonController { protected _getLiveButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && isViewSupportedByCamera('live', cameraManager, view.camera) + return view && + isViewSupportedByCamera('live', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:cctv', ...config.menu.buttons.live, @@ -249,9 +247,11 @@ export class MenuButtonController { protected _getClipsButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && isViewSupportedByCamera('clips', cameraManager, view.camera) + return view && + isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:filmstrip', ...config.menu.buttons.clips, @@ -267,9 +267,11 @@ export class MenuButtonController { protected _getSnapshotsButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && isViewSupportedByCamera('snapshots', cameraManager, view.camera) + return view && + isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:camera', ...config.menu.buttons.snapshots, @@ -285,9 +287,11 @@ export class MenuButtonController { protected _getRecordingsButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && isViewSupportedByCamera('recordings', cameraManager, view.camera) + return view && + isViewSupportedByCamera('recordings', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:album', ...config.menu.buttons.recordings, @@ -303,9 +307,11 @@ export class MenuButtonController { protected _getImageButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - return view && isViewSupportedByCamera('image', cameraManager, view.camera) + return view && + isViewSupportedByCamera('image', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:image', ...config.menu.buttons.image, @@ -320,13 +326,11 @@ export class MenuButtonController { protected _getTimelineButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { - const shouldShowTimelineButton = - view && - (isViewSupportedByCamera('timeline', cameraManager, view.camera) || - isViewSupportedByQueryOnly('timeline', view.query, view.queryResults)); - return shouldShowTimelineButton + return view && + isViewSupportedByCamera('timeline', cameraManager, foldersManager, view.camera) ? { icon: 'mdi:chart-gantt', ...config.menu.buttons.timeline, @@ -550,10 +554,11 @@ export class MenuButtonController { protected _getDisplayModeButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, + foldersManager: FoldersManager, view?: View | null, ): MenuItem | null { const viewCameraIDs = view - ? getCameraIDsForViewName(view.view, cameraManager) + ? getCameraIDsForViewName(view.view, cameraManager, foldersManager) : null; if ( view?.supportsMultipleDisplayModes() && diff --git a/src/components-lib/timeline/controller.ts b/src/components-lib/timeline/controller.ts index 21f68771..9821ebca 100644 --- a/src/components-lib/timeline/controller.ts +++ b/src/components-lib/timeline/controller.ts @@ -1,6 +1,6 @@ import { add, differenceInSeconds, sub } from 'date-fns'; import { LitElement } from 'lit'; -import { isEqual, throttle } from 'lodash'; +import { isEqual, throttle } from 'lodash-es'; import { ViewContext } from 'view'; import { IdType, @@ -16,9 +16,11 @@ import { CameraManager } from '../../camera-manager/manager'; import { rangesOverlap } from '../../camera-manager/range'; import { MediaQuery } from '../../camera-manager/types'; import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; +import { FoldersManager } from '../../card-controller/folders/manager'; import { ViewItemManager } from '../../card-controller/view/item-manager'; import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context'; import { ViewManagerEpoch } from '../../card-controller/view/types'; +import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; import { CameraConfig } from '../../config/schema/cameras'; import { AdvancedCameraCardView } from '../../config/schema/common/const'; import { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails'; @@ -39,7 +41,12 @@ import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; import { ViewMedia } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; -import { EventMediaQuery, MediaQueries, RecordingMediaQuery } from '../../view/query'; +import { + EventMediaQuery, + FolderViewQuery, + Query, + RecordingMediaQuery, +} from '../../view/query'; import { QueryClassifier, QueryType } from '../../view/query-classifier'; import { QueryResults } from '../../view/query-results'; import { mergeViewContext } from '../../view/view'; @@ -47,7 +54,7 @@ import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source'; import { ExtendedTimeline, TimelineItemClickAction, - TimelineKey, + TimelineKeys, TimelineRangeChange, } from './types'; @@ -68,11 +75,13 @@ class ThumbnailDataRequestEvent extends CustomEvent {} interface TimelineControllerOptions { hass?: HomeAssistant; cameraManager?: CameraManager; + conditionStateManager?: ConditionStateManagerReadonlyInterface; + foldersManager?: FoldersManager; viewItemManager?: ViewItemManager; timelineConfig?: TimelineCoreConfig; mini?: boolean; thumbnailConfig?: ThumbnailsControlBaseConfig; - keys?: TimelineKey[]; + keys?: TimelineKeys; } const TIMELINE_TARGET_BAR_ID = 'target_bar'; @@ -97,8 +106,6 @@ export class TimelineController { private _thumbnailConfig: ThumbnailsControlBaseConfig | null = null; - private _keys: TimelineKey[] = []; - private readonly _isHoverableDevice = isHoverableDevice(); // Range changes are volumonous: throttle the calls on seeking. @@ -130,23 +137,25 @@ export class TimelineController { } public setOptions(options: TimelineControllerOptions): void { - if ( - this._cameraManager !== (options?.cameraManager ?? null) || - this._timelineConfig !== (options?.timelineConfig ?? null) || - !isEqual(this._keys, options?.keys ?? null) - ) { - this.destroyTimeline(); + this.destroyTimeline(); - if (options.keys && options.cameraManager && options.timelineConfig) { - this._source = new TimelineDataSource( - options.cameraManager, - options.keys, - options.timelineConfig.events_media_type, - options.timelineConfig.show_recordings, - ); - } else { - this._source = null; - } + if ( + options.keys && + options.cameraManager && + options.foldersManager && + options.conditionStateManager && + options.timelineConfig + ) { + this._source = new TimelineDataSource( + options.cameraManager, + options.foldersManager, + options.conditionStateManager, + options.keys, + options.timelineConfig.events_media_type, + options.timelineConfig.show_recordings, + ); + } else { + this._source = null; } if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) { @@ -181,9 +190,8 @@ export class TimelineController { this._viewItemManager = options?.viewItemManager ?? null; this._timelineConfig = options?.timelineConfig ?? null; this._mini = options?.mini ?? false; - this._keys = options?.keys ?? []; - setOrRemoveAttribute(this._host, !this._mini || this._keys.length > 1, 'groups'); + setOrRemoveAttribute(this._host, this._shouldShowGroups(), 'groups'); } public async setView(viewManagerEpoch: ViewManagerEpoch | null): Promise { @@ -295,7 +303,7 @@ export class TimelineController { } private _shouldShowGroups(): boolean { - return !this._mini || this._keys.length > 1; + return !this._mini || (this._source?.groups.length ?? 0) > 1; } private _setTargetBarAppropriately(targetTime: Date): void { @@ -492,13 +500,16 @@ export class TimelineController { } const view = this._viewManagerEpoch?.manager.getView(); + const id = String(properties.item); + const item = this._source?.dataset.get(id) ?? null; if ( this._ignoreClick || !view || !this._viewManagerEpoch || !this._source || - !properties.what + !properties.what || + !item ) { return; } @@ -510,7 +521,7 @@ export class TimelineController { properties.time && ['background', 'axis'].includes(properties.what) ) { - const query = this._createMediaQueries('recording'); + const query = this._createQuery('recording'); if (query) { await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ baseView: view, @@ -526,7 +537,6 @@ export class TimelineController { } } else if (properties.item && properties.what === 'item') { const cameraID = String(properties.group); - const id = String(properties.item); const criteria = { main: true, @@ -535,8 +545,7 @@ export class TimelineController { const newResults = view.queryResults ?.clone() .resetSelectedResult() - .selectResultIfFound((media) => media.getID() === properties.item, criteria); - + .selectResultIfFound((media) => media.getID() === id, criteria); const selectedItem = newResults?.getSelectedResult(); const context: ViewContext = mergeViewContext(this._getTimelineContext(), { ...(ViewItemClassifier.isEvent(selectedItem) && @@ -554,18 +563,52 @@ export class TimelineController { // gallery (i.e. any case where the thumbnails may not be match the // events on the timeline, e.g. in the snapshots viewer but // mini-timeline showing all media). - const query = this._createMediaQueries('event'); - if (query) { - await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ - params: { view: 'media', query: query }, - queryExecutorOptions: { - selectResult: { - id: id, + // - If a folder media was loaded into the timeline from a prior folder + // query other than the one stored in the view (e.g. user navigated to + // a different folder in the thumbnails carousel). + if (item.query) { + // Item has a reference query (e.g. folders), use that. + const media = this._source.dataset + .get({ + filter: (timelineItem) => item.query === timelineItem.query, + }) + .map((timelineItem) => timelineItem.media) + .filter(isTruthy); + const selectedIndex = media.findIndex((m) => m.getID() === id); + + if (selectedIndex >= 0) { + const queryResults = new QueryResults({ results: media, selectedIndex }); + this._viewManagerEpoch?.manager.setViewByParameters({ + params: { + view: 'media', + query: item.query, + queryResults, }, - rejectResults: (results) => !results.hasResults(), - }, - modifiers: [new MergeContextViewModifier(context)], - }); + modifiers: [new MergeContextViewModifier(context)], + }); + } + } else { + const currentQueryType = + QueryClassifier.getQueryType(view.query) ?? + this._source.getKeyType() === 'camera' + ? 'event' + : this._source.getKeyType() === 'folder' + ? 'folder' + : null; + + const query = currentQueryType ? this._createQuery(currentQueryType) : null; + if (query) { + await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ + params: { view: 'media', query: query }, + queryExecutorOptions: { + selectResult: { + id, + }, + rejectResults: (results) => !results.hasResults(), + }, + modifiers: [new MergeContextViewModifier(context)], + }); + } } } else { this._viewManagerEpoch.manager.setViewByParameters({ @@ -600,12 +643,12 @@ export class TimelineController { }; } - private _createMediaQueries( + private _createQuery( type: QueryType, options?: { window?: TimelineWindow; }, - ): MediaQueries | null { + ): Query | null { if (!this._timeline || !this._source) { return null; } @@ -620,6 +663,9 @@ export class TimelineController { } else if (type === 'recording') { const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow); return queries ? new RecordingMediaQuery(queries) : null; + } else if (type === 'folder') { + const queries = this._source.getTimelineFolderQuery(); + return queries ? new FolderViewQuery(queries) : null; } return null; } @@ -644,20 +690,22 @@ export class TimelineController { return; } - await this._source?.refresh(this._getPrefetchWindow(properties)); + await this._source?.refresh(this._getPrefetchWindow(properties), { + view, + }); const queryType = QueryClassifier.getQueryType(view.query); if (!queryType) { return; } - const mediaQuery = this._createMediaQueries(queryType); - if (!mediaQuery || this._alreadyHasAcceptableMediaQuery(mediaQuery)) { + const query = this._createQuery(queryType); + if (!query || this._alreadyHasAcceptableMediaQuery(query)) { return; } await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ params: { - query: mediaQuery, + query, }, queryExecutorOptions: { selectResult: { @@ -672,25 +720,27 @@ export class TimelineController { }); }; - private _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { + private _alreadyHasAcceptableMediaQuery(freshQuery: Query): boolean { const view = this._viewManagerEpoch?.manager.getView(); const query = view?.query; - if (!this._cameraManager || !query || !QueryClassifier.isMediaQuery(query)) { + if (!this._cameraManager || !query) { return false; } - const currentQueries = query?.getQuery(); const currentResultTimestamp = view?.queryResults?.getResultsTimestamp(); return ( - !!currentQueries && + !!query?.getQuery() && !!currentResultTimestamp && - !!query?.isSupersetOf(freshMediaQuery) && - this._cameraManager.areMediaQueriesResultsFresh( - currentQueries, - currentResultTimestamp, - ) + ((QueryClassifier.isFolderQuery(query) && query.isEqual(freshQuery)) || + (QueryClassifier.isMediaQuery(query) && + QueryClassifier.isMediaQuery(freshQuery) && + query.isSupersetOf(freshQuery) && + this._cameraManager.areMediaQueriesResultsFresh( + currentResultTimestamp, + query.getQuery(), + ))) ); } @@ -743,9 +793,10 @@ export class TimelineController { // (via fetchIfNecessary) may update the timeline contents which causes // the visjs timeline to stop dragging/panning operations which is very // disruptive to the user. - await this._source?.refresh(prefetchedWindow, view); - - this._source.addEventMediaToDataset(view.queryResults?.getResults()); + await this._source?.refresh(prefetchedWindow, { + view, + }); + this._source.addEventMediaToDataset(view.queryResults?.getResults(), view.query); } const currentSelection = this._timeline.getSelection(); @@ -794,13 +845,12 @@ export class TimelineController { // // Also don't generate thumbnails in mini-timelines (they will already have // been generated). - const queryType = QueryClassifier.getQueryType(view.query); if (!queryType) { return; } - const freshMediaQuery = this._createMediaQueries(queryType, { + const freshMediaQuery = this._createQuery(queryType, { window: desiredWindow, }); diff --git a/src/components-lib/timeline/source.ts b/src/components-lib/timeline/source.ts index b2194714..b897a3a5 100644 --- a/src/components-lib/timeline/source.ts +++ b/src/components-lib/timeline/source.ts @@ -1,6 +1,7 @@ import { add, sub } from 'date-fns'; import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; +import { EqualityCache } from '../../cache/equality-cache'; import { CameraManager } from '../../camera-manager/manager'; import { compressRanges, @@ -14,14 +15,17 @@ import { } from '../../camera-manager/types'; import { capEndDate } from '../../camera-manager/utils/cap-end-date'; import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; +import { FoldersManager } from '../../card-controller/folders/manager'; +import { FolderQuery } from '../../card-controller/folders/types'; +import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; import { FolderConfig } from '../../config/schema/folders'; import { ClipsOrSnapshotsOrAll } from '../../types'; import { errorToConsole, ModifyInterface } from '../../utils/basic.js'; import { ViewItem, ViewMedia } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; -import { QueryClassifier } from '../../view/query-classifier'; +import { FolderViewQuery, Query } from '../../view/query'; import { View } from '../../view/view'; -import { TimelineKey } from './types'; +import { TimelineKeys } from './types'; // Allow timeline freshness to be at least this number of seconds out of date // (caching times in the data-engine may increase the effective delay). @@ -33,6 +37,8 @@ const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30; // instead of clean recording blocks. const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60; +type TimelineViewQuery = Query; + export interface AdvancedCameraCardTimelineItem extends TimelineItem { // Use numbers to avoid significant volumes of Date object construction (for // high-quantity recording segments). @@ -43,6 +49,9 @@ export interface AdvancedCameraCardTimelineItem extends TimelineItem { content: string; media?: ViewMedia; + + // View query object from which this timeline item is associated with. + query?: TimelineViewQuery; } interface AdvancedCameraCardGroup { @@ -50,8 +59,14 @@ interface AdvancedCameraCardGroup { content: string; } +interface RefreshOptions { + view?: View; +} + export class TimelineDataSource { private _cameraManager: CameraManager; + private _foldersManager: FoldersManager; + private _conditionStateManager: ConditionStateManagerReadonlyInterface; private _dataset: DataSet = new DataSet(); private _groups: DataSet; @@ -64,23 +79,26 @@ export class TimelineDataSource { // Cache event ranges since re-adding the same events is a timeline // performance killer (even if the request results are cached). private _eventRanges = new ExpiringMemoryRangeSet(); - - private _cameraIDs: Set; + private _folderCache = new EqualityCache(); private _eventsMediaType: ClipsOrSnapshotsOrAll; private _showRecordings: boolean; + private _keys: TimelineKeys; + constructor( cameraManager: CameraManager, - keys: TimelineKey[], + foldersManager: FoldersManager, + conditionStateManager: ConditionStateManagerReadonlyInterface, + keys: TimelineKeys, eventsMediaType: ClipsOrSnapshotsOrAll, showRecordings: boolean, ) { this._cameraManager = cameraManager; + this._foldersManager = foldersManager; + this._conditionStateManager = conditionStateManager; + this._keys = keys; - this._cameraIDs = new Set( - keys.filter((key) => key.type === 'camera').map((key) => key.cameraID), - ); this._groups = this._generateGroups(keys); this._eventsMediaType = eventsMediaType; @@ -91,6 +109,14 @@ export class TimelineDataSource { return this._dataset; } + get groups(): DataSet { + return this._groups; + } + + public getKeyType(): 'camera' | 'folder' { + return this._keys.type; + } + private _getGroupIDForCamera(cameraID: string): string { return `camera/${cameraID}`; } @@ -99,31 +125,28 @@ export class TimelineDataSource { return folderConfig.id; } - private _generateGroups(keys: TimelineKey[]): DataSet { + private _generateGroups(keys: TimelineKeys): DataSet { const groups: AdvancedCameraCardGroup[] = []; - for (const key of keys) { - /* istanbul ignore else: the else path cannot be reached as key can only - be {camera, folder} -- @preserve */ - if (key.type === 'camera') { - const cameraMetadata = this._cameraManager.getCameraMetadata(key.cameraID); + + /* istanbul ignore else: the else path cannot be reached -- @preserve */ + if (keys.type === 'camera') { + keys.cameraIDs?.forEach((cameraID) => { + const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID); groups.push({ - id: this._getGroupIDForCamera(key.cameraID), - content: cameraMetadata?.title ?? key.cameraID, + id: this._getGroupIDForCamera(cameraID), + content: cameraMetadata?.title ?? cameraID, }); - } else if (key.type === 'folder') { - const folderID = this._getGroupIDForFolder(key.folder); - groups.push({ - id: folderID, - content: key.folder.title ?? folderID, - }); - } + }); + } else if (keys.type === 'folder') { + const folderID = this._getGroupIDForFolder(keys.folder); + groups.push({ + id: folderID, + content: keys.folder?.title ?? folderID, + }); } - return new DataSet(groups); - } - get groups(): DataSet { - return this._groups; + return new DataSet(groups); } public rewriteEvent(id: IdType): void { @@ -141,7 +164,10 @@ export class TimelineDataSource { } } - public addEventMediaToDataset(mediaArray?: ViewItem[] | null): void { + public addEventMediaToDataset( + mediaArray?: ViewItem[] | null, + query?: TimelineViewQuery | null, + ): void { const data: AdvancedCameraCardTimelineItem[] = []; for (const media of mediaArray ?? []) { @@ -167,6 +193,7 @@ export class TimelineDataSource { start: startTime.getTime(), type: 'range', end: media.getUsableEndTime()?.getTime(), + ...(query && { query }), }); } } @@ -174,12 +201,20 @@ export class TimelineDataSource { this._dataset.update(data); } - private _shouldUseEventsFromView(view?: View): boolean { - return QueryClassifier.isEventQuery(view?.query); + private async _refreshEvents( + window: TimelineWindow, + options?: RefreshOptions, + ): Promise { + await this._refreshEventsFromCamera(window, options); + await this._refreshEventsFromFolder(); } - private async _refreshEvents(window: TimelineWindow, view?: View): Promise { - if (this._shouldUseEventsFromView(view)) { + private async _refreshEventsFromCamera( + window: TimelineWindow, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _options?: RefreshOptions, + ): Promise { + if (this._keys.type !== 'camera') { return; } @@ -209,10 +244,40 @@ export class TimelineDataSource { }); } - public async refresh(window: TimelineWindow, view?: View): Promise { + private async _refreshEventsFromFolder(): Promise { + if (this._keys.type !== 'folder') { + return; + } + + const folderQuery = this.getTimelineFolderQuery(); + if (!folderQuery) { + return; + } + + const lastDate = this._folderCache.get(folderQuery); + const now = new Date(); + + if ( + lastDate && + lastDate >= sub(now, { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }) + ) { + return; + } + + this.addEventMediaToDataset( + await this._foldersManager.expandFolder( + folderQuery, + this._conditionStateManager.getState(), + ), + new FolderViewQuery(folderQuery), + ); + this._folderCache.set(folderQuery, now); + } + + public async refresh(window: TimelineWindow, options?: RefreshOptions): Promise { try { await Promise.all([ - this._refreshEvents(window, view), + this._refreshEvents(window, options), ...(this._showRecordings ? [this._refreshRecordings(window)] : []), ]); } catch (e) { @@ -226,10 +291,10 @@ export class TimelineDataSource { } public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { - if (!this._cameraIDs.size) { + if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) { return null; } - return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { + return this._cameraManager.generateDefaultEventQueries(this._keys.cameraIDs, { start: window.start, end: window.end, ...(this._eventsMediaType === 'clips' && { hasClip: true }), @@ -238,17 +303,25 @@ export class TimelineDataSource { } public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null { - if (!this._cameraIDs.size) { + if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) { return null; } - return this._cameraManager.generateDefaultRecordingQueries(this._cameraIDs, { + return this._cameraManager.generateDefaultRecordingQueries(this._keys.cameraIDs, { start: window.start, end: window.end, }); } + public getTimelineFolderQuery(): FolderQuery | null { + if (this._keys.type !== 'folder') { + return null; + } + return this._foldersManager.generateDefaultFolderQuery(this._keys.folder); + } + private async _refreshRecordings(window: TimelineWindow): Promise { - if (!this._cameraIDs.size) { + const cameraIDs = this._keys.type === 'camera' ? this._keys.cameraIDs : null; + if (!cameraIDs?.size) { return; } @@ -311,7 +384,7 @@ export class TimelineDataSource { const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window); const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries( - this._cameraIDs, + cameraIDs, { start: cacheFriendlyWindow.start, end: cacheFriendlyWindow.end, diff --git a/src/components-lib/timeline/types.ts b/src/components-lib/timeline/types.ts index 11a4cf0d..511e1595 100644 --- a/src/components-lib/timeline/types.ts +++ b/src/components-lib/timeline/types.ts @@ -21,15 +21,15 @@ export interface ThumbnailDataRequest { export class ThumbnailDataRequestEvent extends CustomEvent {} -interface CameraTimelineKey { - type: 'camera'; - cameraID: string; -} -interface FolderTimelineKey { - type: 'folder'; - folder: FolderConfig; -} -export type TimelineKey = CameraTimelineKey | FolderTimelineKey; +export type TimelineKeys = + | { + type: 'camera'; + cameraIDs: Set; + } + | { + type: 'folder'; + folder: FolderConfig; + }; export interface ExtendedTimeline extends Timeline { // setCustomTimeMarker currently missing from Timeline types. diff --git a/src/components/surround.ts b/src/components/surround.ts index 91ff9def..1ed21121 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -9,11 +9,14 @@ import { import { customElement, property } from 'lit/decorators.js'; import { isEqual } from 'lodash-es'; import { CameraManager } from '../camera-manager/manager.js'; +import { FoldersManager } from '../card-controller/folders/manager.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; -import { TimelineKey } from '../components-lib/timeline/types.js'; +import { TimelineKeys } from '../components-lib/timeline/types.js'; +import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js'; import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js'; +import { FolderConfig } from '../config/schema/folders.js'; import { CardWideConfig } from '../config/schema/types.js'; import { HomeAssistant } from '../ha/types.js'; import basicBlockStyle from '../scss/basic-block.scss'; @@ -40,13 +43,19 @@ export class AdvancedCameraCardSurround extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public foldersManager?: FoldersManager; + + @property({ attribute: false }) + public conditionStateManager?: ConditionStateManagerReadonlyInterface; + @property({ attribute: false }) public viewItemManager?: ViewItemManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _keysForTimeline?: TimelineKey[] = []; + protected _timelineKeys?: TimelineKeys | null = null; /** * Determine if a drawer is being used. @@ -74,24 +83,29 @@ export class AdvancedCameraCardSurround extends LitElement { ) || this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode) ) { - const newKeys = this._getKeysForTimeline(); + const newKeys = this._getTimelineKeys(); // Update only if changed, to avoid unnecessary timeline destructions. - if (!isEqual(newKeys, this._keysForTimeline)) { - this._keysForTimeline = newKeys ?? undefined; + if (!isEqual(newKeys, this._timelineKeys)) { + this._timelineKeys = newKeys ?? undefined; } } } - protected _getKeysForTimeline(): TimelineKey[] | null { - const cameraIDsToKeys = (cameraIDs: Set | null): TimelineKey[] => { - const keys: TimelineKey[] = []; - for (const cameraID of cameraIDs ?? []) { - keys.push({ - type: 'camera', - cameraID: cameraID, - }); - } - return keys; + protected _getTimelineKeys(): TimelineKeys | null { + const cameraIDsToKeys = (cameraIDs: Set | null): TimelineKeys | null => { + return cameraIDs?.size + ? { + type: 'camera', + cameraIDs, + } + : null; + }; + + const folderToKeys = (folderConfig: FolderConfig): TimelineKeys => { + return { + type: 'folder', + folder: folderConfig, + }; }; const view = this.viewManagerEpoch?.manager.getView(); @@ -122,14 +136,7 @@ export class AdvancedCameraCardSurround extends LitElement { return cameraIDsToKeys(queries.getQueryCameraIDs()); } else if (QueryClassifier.isFolderQuery(queries)) { const folderConfig = queries.getQuery()?.folder; - return folderConfig - ? [ - { - type: 'folder' as const, - folder: folderConfig, - }, - ] - : []; + return folderConfig ? folderToKeys(folderConfig) : null; } } @@ -182,11 +189,13 @@ export class AdvancedCameraCardSurround extends LitElement { this.thumbnailConfig?.mode === 'none' ? 'play' : 'select'} - .keys=${this._keysForTimeline} + .keys=${this._timelineKeys} .mini=${true} .timelineConfig=${this.timelineConfig} .thumbnailConfig=${this.thumbnailConfig} .cameraManager=${this.cameraManager} + .foldersManager=${this.foldersManager} + .conditionStateManager=${this.conditionStateManager} .viewItemManager=${this.viewItemManager} .cardWideConfig=${this.cardWideConfig} > diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 2bc06735..633a8c0b 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -10,6 +10,7 @@ import { customElement, property } from 'lit/decorators.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; import type { IdType } from 'vis-timeline/esnext'; import { CameraManager } from '../camera-manager/manager'; +import { FoldersManager } from '../card-controller/folders/manager'; import { ViewItemManager } from '../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; import { TimelineController } from '../components-lib/timeline/controller'; @@ -17,8 +18,9 @@ import { ThumbnailDataRequest, ThumbnailDataRequestEvent, TimelineItemClickAction, - TimelineKey, + TimelineKeys, } from '../components-lib/timeline/types'; +import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails'; import { TimelineCoreConfig } from '../config/schema/common/controls/timeline'; import { CardWideConfig } from '../config/schema/types'; @@ -121,11 +123,17 @@ export class AdvancedCameraCardTimelineCore extends LitElement { // Which cameraIDs to include in the timeline. If not specified, all cameraIDs // are shown. @property({ attribute: false, hasChanged: contentsChanged }) - public keys: TimelineKey[] = []; + public keys?: TimelineKeys; @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public foldersManager?: FoldersManager; + + @property({ attribute: false }) + public conditionStateManager?: ConditionStateManagerReadonlyInterface; + @property({ attribute: false }) public viewItemManager?: ViewItemManager; @@ -144,7 +152,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { return; } - if (!this.keys.length) { + if (!this.keys) { if (!this.mini) { return renderMessage({ message: localize('error.no_camera_or_media_for_timeline'), @@ -228,20 +236,22 @@ export class AdvancedCameraCardTimelineCore extends LitElement { [ 'cameraManager', 'viewItemManager', - 'viewManagerEpoch', 'timelineConfig', 'mini', 'thumbnailConfig', 'keys', + 'conditionStateManager', ].some((prop) => changedProps.has(prop)) ) { this._controller.setOptions({ cameraManager: this.cameraManager, + foldersManager: this.foldersManager, viewItemManager: this.viewItemManager, + conditionStateManager: this.conditionStateManager, timelineConfig: this.timelineConfig, mini: this.mini, thumbnailConfig: this.thumbnailConfig, - keys: this.keys ?? [], + keys: this.keys, }); } } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 667e3330..6ebcdafd 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,9 +1,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager'; +import { FoldersManager } from '../card-controller/folders/manager'; import { ViewItemManager } from '../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; -import { TimelineKey } from '../components-lib/timeline/types'; +import { TimelineKeys } from '../components-lib/timeline/types'; +import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; import { TimelineConfig } from '../config/schema/timeline'; import { CardWideConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; @@ -26,41 +28,57 @@ export class AdvancedCameraCardTimeline extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public foldersManager?: FoldersManager; + + @property({ attribute: false }) + public conditionStateManager?: ConditionStateManagerReadonlyInterface; + @property({ attribute: false }) public viewItemManager?: ViewItemManager; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - protected _getKeys(): TimelineKey[] { + protected _getKeys(): TimelineKeys | undefined { const query = this.viewManagerEpoch?.manager.getView()?.query; // If there's a query, try to extract camera IDs or folder info from it. if (QueryClassifier.isMediaQuery(query)) { const cameraIDs = query.getQueryCameraIDs(); if (cameraIDs && cameraIDs.size) { - return [...cameraIDs].map((cameraID) => ({ type: 'camera', cameraID })); + return { + type: 'camera', + cameraIDs, + }; } } else if (QueryClassifier.isFolderQuery(query)) { const folderConfig = query.getQuery()?.folder; if (folderConfig) { - return [ - { - type: 'folder' as const, - folder: folderConfig, - }, - ]; + return { + type: 'folder', + folder: folderConfig, + }; } } // Otherwise fall back to all cameras that support media queries. - const keys: TimelineKey[] = []; - for (const camera of this.cameraManager?.getStore().getCameraIDsWithCapability({ + const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability({ anyCapabilities: ['clips', 'snapshots', 'recordings'], - }) ?? []) { - keys.push({ type: 'camera', cameraID: camera }); - } - return keys; + }); + const folder = this.foldersManager?.getFolder() ?? null; + + return cameraIDs?.size + ? { + type: 'camera', + cameraIDs, + } + : folder + ? { + type: 'folder', + folder, + } + : undefined; } protected render(): TemplateResult | void { @@ -75,6 +93,8 @@ export class AdvancedCameraCardTimeline extends LitElement { .timelineConfig=${this.timelineConfig} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameraManager=${this.cameraManager} + .foldersManager=${this.foldersManager} + .conditionStateManager=${this.conditionStateManager} .viewItemManager=${this.viewItemManager} .keys=${this._getKeys()} .cardWideConfig=${this.cardWideConfig} diff --git a/src/components/views.ts b/src/components/views.ts index 555dbbcd..ae6616fd 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -9,6 +9,7 @@ import { import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; +import { FoldersManager } from '../card-controller/folders/manager.js'; import { MicrophoneState } from '../card-controller/types.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; @@ -36,6 +37,9 @@ export class AdvancedCameraCardViews extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public foldersManager?: FoldersManager; + @property({ attribute: false }) public viewItemManager?: ViewItemManager; @@ -148,6 +152,8 @@ export class AdvancedCameraCardViews extends LitElement { .thumbnailConfig=${!this.hide ? thumbnailConfig : undefined} .timelineConfig=${!this.hide ? miniTimelineConfig : undefined} .cameraManager=${this.cameraManager} + .foldersManager=${this.foldersManager} + .conditionStateManager=${this.conditionStateManager} .viewItemManager=${this.viewItemManager} .cardWideConfig=${this.cardWideConfig} > @@ -191,6 +197,8 @@ export class AdvancedCameraCardViews extends LitElement { .viewManagerEpoch=${this.viewManagerEpoch} .timelineConfig=${this.config.timeline} .cameraManager=${this.cameraManager} + .conditionStateManager=${this.conditionStateManager} + .foldersManager=${this.foldersManager} .viewItemManager=${this.viewItemManager} .cardWideConfig=${this.cardWideConfig} > diff --git a/src/view/query.ts b/src/view/query.ts index 446c0796..be7a8f68 100644 --- a/src/view/query.ts +++ b/src/view/query.ts @@ -27,6 +27,10 @@ class ViewQuery { public clone(): this { return cloneDeep(this); } + + public isEqual(that: Query): boolean { + return isEqual(this._query, that.getQuery()); + } } class MediaQueryBase extends ViewQuery { diff --git a/src/view/view-support.ts b/src/view/view-support.ts index e4d60379..2fb76f71 100644 --- a/src/view/view-support.ts +++ b/src/view/view-support.ts @@ -1,8 +1,7 @@ import { CameraManager } from '../camera-manager/manager'; import { CapabilitySearchOptions } from '../camera-manager/types'; +import { FoldersManager } from '../card-controller/folders/manager'; import { AdvancedCameraCardView } from '../config/schema/common/const'; -import { Query } from './query'; -import { QueryResults } from './query-results'; /** * Get cameraIDs that are relevant for a given view name based on camera @@ -11,16 +10,21 @@ import { QueryResults } from './query-results'; export const getCameraIDsForViewName = ( viewName: AdvancedCameraCardView, cameraManager: CameraManager, + foldersManager: FoldersManager, cameraID?: string, ): Set => { + const folder = foldersManager.getFolder(); + switch (viewName) { case 'diagnostics': case 'image': - case 'folder': - case 'folders': case 'media': return cameraManager.getStore().getCameraIDs(); + case 'folder': + case 'folders': + return folder ? cameraManager.getStore().getCameraIDs() : new Set(); + case 'live': case 'clip': case 'clips': @@ -44,32 +48,19 @@ export const getCameraIDsForViewName = ( : cameraManager.getStore().getCameraIDsWithCapability(capability, options); case 'timeline': - return cameraManager.getStore().getCameraIDsWithCapability({ - anyCapabilities: ['clips', 'snapshots', 'recordings'], - }); + return folder + ? cameraManager.getStore().getCameraIDs() + : cameraManager.getStore().getCameraIDsWithCapability({ + anyCapabilities: ['clips', 'snapshots', 'recordings'], + }); } }; export const isViewSupportedByCamera = ( view: AdvancedCameraCardView, cameraManager: CameraManager, + foldersManager: FoldersManager, cameraID: string, ): boolean => { - return !!getCameraIDsForViewName(view, cameraManager, cameraID).size; -}; - -/** - * Whether a view is supported by a given query ONLY (i.e. regardless of what - * the camera supports). - */ -export const isViewSupportedByQueryOnly = ( - view: AdvancedCameraCardView, - query?: Query | null, - queryResults?: QueryResults | null, -): boolean => { - switch (view) { - case 'timeline': - return !!query?.getQuery() && !!queryResults?.hasResults(); - } - return false; + return !!getCameraIDsForViewName(view, cameraManager, foldersManager, cameraID)?.size; }; diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 59f45496..2f6b0bb6 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -1188,11 +1188,16 @@ describe('CameraManager', async () => { expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); engine.getQueryResultMaxAge.mockReturnValue(60); - expect(manager.areMediaQueriesResultsFresh(queries, resultsTimestamp)).toBe( + expect(manager.areMediaQueriesResultsFresh(resultsTimestamp, queries)).toBe( expectedFresh, ); }, ); + + it('should always return false for null queries', () => { + const manager = createCameraManager(createCardAPI(), mock()); + expect(manager.areMediaQueriesResultsFresh(new Date(), null)).toBe(false); + }); }); describe('should get media seek time', () => { diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 06253eb0..99810650 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -53,6 +53,7 @@ const calculateButtons = ( hass?: HomeAssistant; config?: AdvancedCameraCardConfig; cameraManager?: CameraManager; + foldersManager?: FoldersManager; view?: View | null; viewManager?: ViewManager; }, @@ -66,6 +67,7 @@ const calculateButtons = ( options?.hass ?? createHASS(), options?.config ?? createConfig(), cameraManager, + options?.foldersManager ?? mock(), { ...options, view: diff --git a/tests/components-lib/timeline/source.test.ts b/tests/components-lib/timeline/source.test.ts index 6658ab93..de8ed1f7 100644 --- a/tests/components-lib/timeline/source.test.ts +++ b/tests/components-lib/timeline/source.test.ts @@ -1,6 +1,8 @@ +import { add } from 'date-fns'; import { DataSet } from 'vis-data'; import { TimelineWindow } from 'vis-timeline'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../../src/camera-manager/manager'; import { Engine, @@ -11,13 +13,15 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResults, } from '../../../src/camera-manager/types'; +import { FoldersManager } from '../../../src/card-controller/folders/manager'; import { AdvancedCameraCardTimelineItem, TimelineDataSource, } from '../../../src/components-lib/timeline/source'; -import { TimelineKey } from '../../../src/components-lib/timeline/types'; +import { TimelineKeys } from '../../../src/components-lib/timeline/types'; +import { ConditionStateManagerReadonlyInterface } from '../../../src/conditions/types'; import { ViewMediaType } from '../../../src/view/item'; -import { EventMediaQuery } from '../../../src/view/query'; +import { FolderViewQuery } from '../../../src/view/query'; import { createCameraManager, createFolder, @@ -26,7 +30,7 @@ import { TestViewMedia, } from '../../test-utils'; -const CAMERA_ID = 'CAMERA_ID'; +const CAMERA_ID = 'camera-1'; const TEST_MEDIA_ID = 'TEST_MEDIA_ID'; const RECORDING_SEGMENT_ID = 'SEGMENT_ID'; const EXPECTED_RECORDING_ID = `recording-${CAMERA_ID}-${RECORDING_SEGMENT_ID}`; @@ -34,13 +38,21 @@ const EXPECTED_RECORDING_ID = `recording-${CAMERA_ID}-${RECORDING_SEGMENT_ID}`; const start = new Date('2025-09-21T19:31:06Z'); const end = new Date('2025-09-21T19:31:15Z'); -const testMedia = new TestViewMedia({ +const testCameraMedia = new TestViewMedia({ cameraID: CAMERA_ID, id: TEST_MEDIA_ID, startTime: start, endTime: end, }); +const folder = createFolder({ id: 'folder/folder-1', title: 'Folder Title' }); +const testFolderMedia = new TestViewMedia({ + folder, + id: TEST_MEDIA_ID, + startTime: start, + endTime: end, +}); + const createTestCameraManager = (): CameraManager => { const cameraManager = createCameraManager( createStore([ @@ -62,7 +74,7 @@ const createTestCameraManager = (): CameraManager => { }; vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([eventQuery]); - vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([testMedia]); + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue([testCameraMedia]); const recordingSegmentQuery: RecordingSegmentsQuery = { type: QueryType.RecordingSegments, @@ -92,33 +104,83 @@ const createTestCameraManager = (): CameraManager => { }; describe('TimelineDataSource', () => { - const folder = createFolder({ id: 'folder/FOLDER_ID', title: 'Folder Title' }); - const timelineKeys: TimelineKey[] = [ - { type: 'camera', cameraID: 'CAMERA_ID' }, - { type: 'folder', folder: folder }, - ]; + const cameraTimelineKeys: TimelineKeys = { + type: 'camera', + cameraIDs: new Set(['camera-1', 'camera-2']), + }; + const folderTimelineKeys: TimelineKeys = { + type: 'folder', + folder, + }; beforeEach(() => { vi.clearAllMocks(); }); - describe('should get groups', () => { - it('should get mixed groups', () => { + describe('should get key type', () => { + it('should get camera key type', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); + + expect(source.getKeyType()).toBe('camera'); + }); + + it('should get folder key type', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + mock(), + mock(), + folderTimelineKeys, + 'all', + true, + ); + + expect(source.getKeyType()).toBe('folder'); + }); + }); + + describe('should get groups', () => { + it('should get camera based groups', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); expect(source.groups.length).toBe(2); - expect(source.groups.get('camera/CAMERA_ID')).toEqual({ + expect(source.groups.get('camera/camera-1')).toEqual({ content: 'Camera Title', - id: 'camera/CAMERA_ID', + id: 'camera/camera-1', }); - expect(source.groups.get('folder/FOLDER_ID')).toEqual({ + expect(source.groups.get('camera/camera-2')).toEqual({ + content: 'Camera Title', + id: 'camera/camera-2', + }); + }); + + it('should get folder based groups', () => { + const source = new TimelineDataSource( + createTestCameraManager(), + mock(), + mock(), + folderTimelineKeys, + 'all', + true, + ); + + expect(source.groups.length).toBe(1); + expect(source.groups.get('folder/folder-1')).toEqual({ content: 'Folder Title', - id: 'folder/FOLDER_ID', + id: 'folder/folder-1', }); }); @@ -126,28 +188,58 @@ describe('TimelineDataSource', () => { const cameraManager = createTestCameraManager(); vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); - expect(source.groups.get('camera/CAMERA_ID')).toEqual({ - content: 'CAMERA_ID', - id: 'camera/CAMERA_ID', + expect(source.groups.get('camera/camera-1')).toEqual({ + content: 'camera-1', + id: 'camera/camera-1', }); }); it('should use folder id if folder has no title', () => { - const folder = createFolder({ id: 'folder/FOLDER_ID' }); - const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; + const folder = createFolder({ id: 'folder/folder-1' }); + const timelineKeys: TimelineKeys = { type: 'folder', folder }; const source = new TimelineDataSource( createTestCameraManager(), + mock(), + mock(), timelineKeys, 'all', true, ); - expect(source.groups.get('folder/FOLDER_ID')).toEqual({ - content: 'folder/FOLDER_ID', - id: 'folder/FOLDER_ID', + expect(source.groups.length).toBe(1); + expect(source.groups.get('folder/folder-1')).toEqual({ + content: 'folder/folder-1', + id: 'folder/folder-1', + }); + }); + + it('should have no groups if a folder key has no folder', () => { + const folder = createFolder({ id: 'folder/folder-1' }); + const timelineKeys: TimelineKeys = { type: 'folder', folder }; + + const source = new TimelineDataSource( + createTestCameraManager(), + mock(), + mock(), + timelineKeys, + 'all', + true, + ); + + expect(source.groups.length).toBe(1); + expect(source.groups.get('folder/folder-1')).toEqual({ + content: 'folder/folder-1', + id: 'folder/folder-1', }); }); }); @@ -158,7 +250,7 @@ describe('TimelineDataSource', () => { const endTime = new Date('2025-09-21T15:35:28Z'); const id = 'EVENT_ID'; const media = new TestViewMedia({ - cameraID: 'CAMERA_ID', + cameraID: 'camera-1', id, startTime, endTime, @@ -166,7 +258,9 @@ describe('TimelineDataSource', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); @@ -178,7 +272,7 @@ describe('TimelineDataSource', () => { start: startTime.getTime(), end: endTime.getTime(), media, - group: 'camera/CAMERA_ID', + group: 'camera/camera-1', content: '', type: 'range', }); @@ -200,7 +294,9 @@ describe('TimelineDataSource', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + folderTimelineKeys, 'all', true, ); @@ -221,7 +317,9 @@ describe('TimelineDataSource', () => { it('should ignore non-events media', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); @@ -238,7 +336,9 @@ describe('TimelineDataSource', () => { it('should ignore null results', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); @@ -251,7 +351,9 @@ describe('TimelineDataSource', () => { it('should ignore media without camera or folder ownership', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); @@ -279,83 +381,195 @@ describe('TimelineDataSource', () => { vi.restoreAllMocks(); }); - it('should refresh events successfully', async () => { - const source = new TimelineDataSource( - createTestCameraManager(), - timelineKeys, - 'all', - false, - ); - const view = createView(); + describe('should refresh events from camera', () => { + it('should refresh events successfully', async () => { + const source = new TimelineDataSource( + createTestCameraManager(), + mock(), + mock(), + cameraTimelineKeys, + 'all', + false, + ); + const view = createView(); - await source.refresh(window, view); + await source.refresh(window, { view }); - expect(source.dataset.length).toBe(1); + expect(source.dataset.length).toBe(1); - expect(source.dataset.get('TEST_MEDIA_ID')).toEqual({ - id: 'TEST_MEDIA_ID', - content: '', - start: new Date('2025-09-21T19:31:06Z').getTime(), - end: new Date('2025-09-21T19:31:15Z').getTime(), - media: testMedia, - type: 'range', - group: 'camera/CAMERA_ID', + expect(source.dataset.get('TEST_MEDIA_ID')).toEqual({ + id: 'TEST_MEDIA_ID', + content: '', + start: new Date('2025-09-21T19:31:06Z').getTime(), + end: new Date('2025-09-21T19:31:15Z').getTime(), + media: testCameraMedia, + type: 'range', + group: 'camera/camera-1', + }); + }); + + it('should refresh events and handle exception', async () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue( + new Error('Error fetching events'), + ); + + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + false, + ); + + expect(source.dataset.length).toBe(0); + + await source.refresh(window); + + expect(source.dataset.length).toBe(0); + + expect(consoleSpy).toHaveBeenCalledWith('Error fetching events'); + }); + + it('should not refresh events when window is cached', async () => { + const cameraManager = createTestCameraManager(); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + false, + ); + const view = createView(); + + await source.refresh(window, { view }); + expect(source.dataset.length).toBe(1); + + await source.refresh(window, { view }); + expect(source.dataset.length).toBe(1); + expect(cameraManager.executeMediaQueries).toHaveBeenCalledTimes(1); + }); + + it('should not refresh events when unable to create event queries', async () => { + const cameraManager = createTestCameraManager(); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null); + + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + false, + ); + + await source.refresh(window); + expect(source.dataset.length).toBe(0); }); }); - it('should refresh events and handle exception', async () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + describe('should refresh events from folder', () => { + it('should refresh events successfully', async () => { + const foldersManager = mock(); + vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ + folder, + path: [{}], + }); + vi.mocked(foldersManager.expandFolder).mockResolvedValue([testFolderMedia]); - const cameraManager = createTestCameraManager(); - vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue( - new Error('Error fetching events'), - ); + const source = new TimelineDataSource( + mock(), + foldersManager, + mock(), + folderTimelineKeys, + 'all', + false, + ); + const view = createView(); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', false); + await source.refresh(window, { view }); - expect(source.dataset.length).toBe(0); + expect(source.dataset.length).toBe(1); - await source.refresh(window); + expect(source.dataset.get('TEST_MEDIA_ID')).toEqual({ + id: 'TEST_MEDIA_ID', + content: '', + start: new Date('2025-09-21T19:31:06Z').getTime(), + end: new Date('2025-09-21T19:31:15Z').getTime(), + media: testFolderMedia, + type: 'range', + group: 'folder/folder-1', + query: expect.any(FolderViewQuery), + }); + }); - expect(source.dataset.length).toBe(0); + describe('should refresh events from folder cached', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); - expect(consoleSpy).toHaveBeenCalledWith('Error fetching events'); - }); + afterAll(() => { + vi.useRealTimers(); + }); - it('should not refresh events when window is cached', async () => { - const cameraManager = createTestCameraManager(); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', false); - const view = createView(); + it('should refresh events only when not cached', async () => { + const foldersManager = mock(); + vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ + folder, + path: [{}], + }); + vi.mocked(foldersManager.expandFolder).mockResolvedValue([testFolderMedia]); - await source.refresh(window, view); - expect(source.dataset.length).toBe(1); + const source = new TimelineDataSource( + mock(), + foldersManager, + mock(), + folderTimelineKeys, + 'all', + false, + ); + const view = createView(); - await source.refresh(window, view); - expect(source.dataset.length).toBe(1); - expect(cameraManager.executeMediaQueries).toHaveBeenCalledTimes(1); - }); + await source.refresh(window, { view }); + await source.refresh(window, { view }); + await source.refresh(window, { view }); - it('should not refresh events when events in view', async () => { - const source = new TimelineDataSource( - createTestCameraManager(), - timelineKeys, - 'all', - false, - ); + expect(foldersManager.expandFolder).toHaveBeenCalledTimes(1); + }); - await source.refresh(window, createView({ query: new EventMediaQuery() })); + it('should refresh events when cached expired', async () => { + const start = new Date(); + vi.setSystemTime(start); + const foldersManager = mock(); + vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ + folder, + path: [{}], + }); + vi.mocked(foldersManager.expandFolder).mockResolvedValue([testFolderMedia]); - expect(source.dataset.length).toBe(0); - }); + const source = new TimelineDataSource( + mock(), + foldersManager, + mock(), + folderTimelineKeys, + 'all', + false, + ); + const view = createView(); - it('should not refresh events when unable to create event queries', async () => { - const cameraManager = createTestCameraManager(); - vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null); + await source.refresh(window, { view }); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', false); + vi.setSystemTime(add(start, { hours: 1 })); - await source.refresh(window); - expect(source.dataset.length).toBe(0); + await source.refresh(window, { view }); + + expect(foldersManager.expandFolder).toHaveBeenCalledTimes(2); + }); + }); }); }); @@ -369,7 +583,9 @@ describe('TimelineDataSource', () => { it('should refresh recordings successfully', async () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); @@ -382,7 +598,7 @@ describe('TimelineDataSource', () => { expect(source.dataset.get(EXPECTED_RECORDING_ID)).toEqual({ content: '', end: 1695307875000, - group: 'camera/CAMERA_ID', + group: 'camera/camera-1', id: EXPECTED_RECORDING_ID, start: 1695307866000, type: 'background', @@ -397,7 +613,14 @@ describe('TimelineDataSource', () => { new Error('Error fetching recordings'), ); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); expect(getRecordings(source.dataset).length).toBe(0); @@ -410,7 +633,14 @@ describe('TimelineDataSource', () => { it('should not refresh recordings when window is cached', async () => { const cameraManager = createTestCameraManager(); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); await source.refresh(window); expect(getRecordings(source.dataset).length).toBe(1); @@ -424,7 +654,9 @@ describe('TimelineDataSource', () => { it('should not refresh recordings when recordings disabled', async () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', // Disable recordings. @@ -436,12 +668,31 @@ describe('TimelineDataSource', () => { expect(source.dataset.get(EXPECTED_RECORDING_ID)).toBeNull(); }); - it('should not refresh recordings without any cameras', async () => { - const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; - + it('should not refresh recordings with folders', async () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + folderTimelineKeys, + 'all', + true, + ); + + await source.refresh(window); + + expect(source.dataset.get(EXPECTED_RECORDING_ID)).toBeNull(); + }); + + it('should not refresh recordings without cameras', async () => { + const emptycameraTimelineKeys: TimelineKeys = { + type: 'camera', + cameraIDs: new Set(), + }; + const source = new TimelineDataSource( + createTestCameraManager(), + mock(), + mock(), + emptycameraTimelineKeys, 'all', true, ); @@ -457,7 +708,14 @@ describe('TimelineDataSource', () => { null, ); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); await source.refresh(window); @@ -499,7 +757,14 @@ describe('TimelineDataSource', () => { ]), ); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); await source.refresh(window); @@ -507,8 +772,8 @@ describe('TimelineDataSource', () => { { content: '', end: 1695307885000, - group: 'camera/CAMERA_ID', - id: 'recording-CAMERA_ID-SEGMENT_ID', + group: 'camera/camera-1', + id: 'recording-camera-1-SEGMENT_ID', start: 1695307866000, type: 'background', }, @@ -547,15 +812,22 @@ describe('TimelineDataSource', () => { new Map([[recordingSegmentQuery, recordingSegmentsQueryResults]]), ); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); source.dataset.add({ - id: 'recording-CAMERA_ID-SEGMENT_ID', + id: 'recording-camera-1-SEGMENT_ID', start: 1695307866000, // No end time. end: undefined, - group: 'camera/CAMERA_ID', + group: 'camera/camera-1', content: '', type: 'background', }); @@ -566,8 +838,8 @@ describe('TimelineDataSource', () => { { content: '', end: 1695307885000, - group: 'camera/CAMERA_ID', - id: 'recording-CAMERA_ID-SEGMENT_ID', + group: 'camera/camera-1', + id: 'recording-camera-1-SEGMENT_ID', start: 1695307866000, type: 'background', }, @@ -622,7 +894,14 @@ describe('TimelineDataSource', () => { ]), ); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); await source.refresh(window); @@ -653,10 +932,11 @@ describe('TimelineDataSource', () => { const window: TimelineWindow = { start, end }; it('should not event queries without cameras', () => { - const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; const source = new TimelineDataSource( createCameraManager(), - timelineKeys, + mock(), + mock(), + folderTimelineKeys, 'all', true, ); @@ -672,11 +952,18 @@ describe('TimelineDataSource', () => { }, ]), ); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'clips', false); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'clips', + false, + ); source.getTimelineEventQueries(window); expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( - new Set([CAMERA_ID]), + new Set(['camera-1', 'camera-2']), { start, end, @@ -695,14 +982,16 @@ describe('TimelineDataSource', () => { ); const source = new TimelineDataSource( cameraManager, - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'snapshots', false, ); source.getTimelineEventQueries(window); expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( - new Set([CAMERA_ID]), + new Set(['camera-1', 'camera-2']), { start, end, @@ -712,14 +1001,51 @@ describe('TimelineDataSource', () => { }); }); + describe('should get timeline folder queries', () => { + it('should not get folder queries without a folder', () => { + const source = new TimelineDataSource( + createCameraManager(), + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); + + expect(source.getTimelineFolderQuery()).toBeNull(); + }); + + it('should get folder queries', () => { + const foldersManager = mock(); + vi.mocked(foldersManager.generateDefaultFolderQuery).mockReturnValue({ + folder, + path: [{}], + }); + + const source = new TimelineDataSource( + mock(), + foldersManager, + mock(), + folderTimelineKeys, + 'clips', + false, + ); + + source.getTimelineFolderQuery(); + + expect(foldersManager.generateDefaultFolderQuery).toBeCalledWith(folder); + }); + }); + describe('should get timeline recording queries', () => { const window: TimelineWindow = { start, end }; it('should not recording queries without cameras', () => { - const timelineKeys: TimelineKey[] = [{ type: 'folder', folder: folder }]; const source = new TimelineDataSource( createCameraManager(), - timelineKeys, + mock(), + mock(), + folderTimelineKeys, 'all', true, ); @@ -731,15 +1057,25 @@ describe('TimelineDataSource', () => { const cameraManager = createCameraManager( createStore([ { - cameraID: CAMERA_ID, + cameraID: 'camera-1', + }, + { + cameraID: 'camera-2', }, ]), ); - const source = new TimelineDataSource(cameraManager, timelineKeys, 'all', true); + const source = new TimelineDataSource( + cameraManager, + mock(), + mock(), + cameraTimelineKeys, + 'all', + true, + ); source.getTimelineRecordingQueries(window); expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith( - new Set([CAMERA_ID]), + new Set(['camera-1', 'camera-2']), { start, end, @@ -752,7 +1088,9 @@ describe('TimelineDataSource', () => { it('should not rewrite when item is not found', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); @@ -764,7 +1102,9 @@ describe('TimelineDataSource', () => { it('should not rewrite when item is not found', () => { const source = new TimelineDataSource( createTestCameraManager(), - timelineKeys, + mock(), + mock(), + cameraTimelineKeys, 'all', true, ); @@ -772,8 +1112,8 @@ describe('TimelineDataSource', () => { id: 'id', start: start.getTime(), end: end.getTime(), - media: testMedia, - group: 'camera/CAMERA_ID' as const, + media: testCameraMedia, + group: 'camera/camera-1' as const, content: '', type: 'range' as const, }; diff --git a/tests/view/query.test.ts b/tests/view/query.test.ts index afec5f4a..a2c8a3e7 100644 --- a/tests/view/query.test.ts +++ b/tests/view/query.test.ts @@ -6,6 +6,7 @@ import { QueryType, RecordingQuery, } from '../../src/camera-manager/types'; +import { FolderQuery } from '../../src/card-controller/folders/types'; import { setify } from '../../src/utils/basic'; import { EventMediaQuery, @@ -70,6 +71,28 @@ describe('EventMediaQuery', () => { expect(query.convertToClipsQueries().getQuery()).toBeNull(); }); + describe('should determine equality', () => { + it('should return true when query is equal', () => { + const rawQueries_1 = createRawEventQueries('office', { hasSnapshot: true }); + const query_1 = new EventMediaQuery(rawQueries_1); + + const rawQueries_2 = createRawEventQueries('office', { hasSnapshot: true }); + const query_2 = new EventMediaQuery(rawQueries_2); + + expect(query_1.isEqual(query_2)).toBeTruthy(); + }); + + it('should return false when query is not equal', () => { + const rawQueries_1 = createRawEventQueries('office', { hasSnapshot: true }); + const query_1 = new EventMediaQuery(rawQueries_1); + + const rawQueries_2 = createRawEventQueries('office', { hasSnapshot: false }); + const query_2 = new EventMediaQuery(rawQueries_2); + + expect(query_1.isEqual(query_2)).toBeFalsy(); + }); + }); + it('should clone', () => { const rawQueries = createRawEventQueries('office', { hasSnapshot: true }); const query = new EventMediaQuery(rawQueries); @@ -288,9 +311,9 @@ describe('RecordingMediaQuery', () => { describe('FolderViewQuery', () => { it('should construct', () => { - const rawQuery = { + const rawQuery: FolderQuery = { folder: createFolder(), - path: 'path', + path: [{}], }; const query = new FolderViewQuery(rawQuery); diff --git a/tests/view/view-support.test.ts b/tests/view/view-support.test.ts index dc6f67dc..d611fa70 100644 --- a/tests/view/view-support.test.ts +++ b/tests/view/view-support.test.ts @@ -1,52 +1,48 @@ import { describe, expect, it, vi } from 'vitest'; -import { QueryType } from '../../src/camera-manager/types'; +import { mock } from 'vitest-mock-extended'; +import { FoldersManager } from '../../src/card-controller/folders/manager'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const'; import { CapabilityKey } from '../../src/types'; -import { EventMediaQuery } from '../../src/view/query'; -import { QueryResults } from '../../src/view/query-results'; import { getCameraIDsForViewName, isViewSupportedByCamera, - isViewSupportedByQueryOnly, } from '../../src/view/view-support'; import { createCameraConfig, createCameraManager, createCapabilities, + createFolder, createStore, - generateViewMediaArray, } from '../test-utils'; describe('getCameraIDsForViewName', () => { describe('views that are always supported', () => { - it.each([ - ['diagnostics' as const], - ['folder' as const], - ['folders' as const], - ['image' as const], - ['media' as const], - ])('%s', (viewName: AdvancedCameraCardView) => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), - }, - { cameraID: 'camera-2' }, - ]), - ); + it.each([['diagnostics' as const], ['image' as const], ['media' as const]])( + '%s', + (viewName: AdvancedCameraCardView) => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { cameraID: 'camera-2' }, + ]), + ); + const foldersManager = mock(); - expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual( - new Set(['camera-1', 'camera-2']), - ); - expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-1')).toEqual( - new Set(['camera-1', 'camera-2']), - ); - expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-2')).toEqual( - new Set(['camera-1', 'camera-2']), - ); - }); + expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( + new Set(['camera-1', 'camera-2']), + ); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), + ).toEqual(new Set(['camera-1', 'camera-2'])); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + ).toEqual(new Set(['camera-1', 'camera-2'])); + }, + ); }); describe('views that respect dependencies and need a capability', () => { @@ -69,8 +65,9 @@ describe('getCameraIDsForViewName', () => { }, ]), ); + const foldersManager = mock(); - expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( new Set(['camera-2']), ); }); @@ -96,15 +93,82 @@ describe('getCameraIDsForViewName', () => { }, ]), ); + const foldersManager = mock(); - expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual( + expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( new Set(['camera-1', 'camera-2']), ); - expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-1')).toEqual( - new Set(['camera-1', 'camera-2']), + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), + ).toEqual(new Set(['camera-1', 'camera-2'])); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + ).toEqual(new Set(['camera-2'])); + }); + }); + + describe('views that respect a folder', () => { + describe('should return cameras when a folder is present', () => { + it.each([['folder' as const], ['folders' as const], ['timeline' as const]])( + '%s', + (viewName: AdvancedCameraCardView) => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { + cameraID: 'camera-2', + }, + ]), + ); + const foldersManager = mock(); + vi.mocked(foldersManager.getFolder).mockReturnValue(createFolder()); + + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager), + ).toEqual(new Set(['camera-1', 'camera-2'])); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), + ).toEqual(new Set(['camera-1', 'camera-2'])); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + ).toEqual(new Set(['camera-1', 'camera-2'])); + }, ); - expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-2')).toEqual( - new Set(['camera-2']), + }); + + describe('should not return cameras when a folder is absent', () => { + it.each([['folder' as const], ['folders' as const], ['timeline' as const]])( + '%s', + (viewName: AdvancedCameraCardView) => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { + cameraID: 'camera-2', + }, + ]), + ); + const foldersManager = mock(); + vi.mocked(foldersManager.getFolder).mockReturnValue(null); + + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager), + ).toEqual(new Set()); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), + ).toEqual(new Set()); + expect( + getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), + ).toEqual(new Set()); + }, ); }); }); @@ -121,8 +185,11 @@ describe('isViewSupportedByCamera', () => { }, ]), ); + const foldersManager = mock(); - expect(isViewSupportedByCamera('live', cameraManager, 'camera-1')).toBe(true); + expect( + isViewSupportedByCamera('live', cameraManager, foldersManager, 'camera-1'), + ).toBe(true); }); it('should return false for unsupported view', () => { @@ -135,43 +202,10 @@ describe('isViewSupportedByCamera', () => { }, ]), ); + const foldersManager = mock(); - expect(isViewSupportedByCamera('live', cameraManager, 'camera-1')).toBe(false); - }); -}); - -describe('isViewSupportedByQueryOnly', () => { - it.each([ - ['live' as const], - ['image' as const], - ['diagnostics' as const], - ['clip' as const], - ['clips' as const], - ['snapshot' as const], - ['snapshots' as const], - ['recording' as const], - ['recordings' as const], - ['media' as const], - ])('%s', (viewName: AdvancedCameraCardView) => { - expect(isViewSupportedByQueryOnly(viewName)).toBe(false); - }); - - it('should return false for timeline without query', () => { - expect(isViewSupportedByQueryOnly('timeline')).toBe(false); - }); - - it('should return true for timeline with query and query results', () => { - const query = new EventMediaQuery([ - { - type: QueryType.Event, - cameraIDs: new Set(['camera-1']), - }, - ]); - const queryResults = new QueryResults({ - results: generateViewMediaArray({ count: 5 }), - selectedIndex: 0, - }); - - expect(isViewSupportedByQueryOnly('timeline', query, queryResults)).toBe(true); + expect( + isViewSupportedByCamera('live', cameraManager, foldersManager, 'camera-1'), + ).toBe(false); }); });