fix: Fix folder media in timeline when camera is not timeline supported (#2246)

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