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