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

- Closes: #2205
This commit is contained in:
Dermot Duffy
2025-11-24 14:33:06 -08:00
committed by GitHub
parent f50bef7446
commit 1f6a4ddce2
20 changed files with 1011 additions and 421 deletions
+111 -61
View File
@@ -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,23 +137,25 @@ export class TimelineController {
}
public setOptions(options: TimelineControllerOptions): void {
if (
this._cameraManager !== (options?.cameraManager ?? null) ||
this._timelineConfig !== (options?.timelineConfig ?? null) ||
!isEqual(this._keys, options?.keys ?? null)
) {
this.destroyTimeline();
this.destroyTimeline();
if (options.keys && options.cameraManager && options.timelineConfig) {
this._source = new TimelineDataSource(
options.cameraManager,
options.keys,
options.timelineConfig.events_media_type,
options.timelineConfig.show_recordings,
);
} else {
this._source = null;
}
if (
options.keys &&
options.cameraManager &&
options.foldersManager &&
options.conditionStateManager &&
options.timelineConfig
) {
this._source = new TimelineDataSource(
options.cameraManager,
options.foldersManager,
options.conditionStateManager,
options.keys,
options.timelineConfig.events_media_type,
options.timelineConfig.show_recordings,
);
} else {
this._source = null;
}
if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) {
@@ -181,9 +190,8 @@ export class TimelineController {
this._viewItemManager = options?.viewItemManager ?? null;
this._timelineConfig = options?.timelineConfig ?? null;
this._mini = options?.mini ?? false;
this._keys = options?.keys ?? [];
setOrRemoveAttribute(this._host, !this._mini || this._keys.length > 1, 'groups');
setOrRemoveAttribute(this._host, this._shouldShowGroups(), 'groups');
}
public async setView(viewManagerEpoch: ViewManagerEpoch | null): Promise<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,18 +563,52 @@ export class TimelineController {
// gallery (i.e. any case where the thumbnails may not be match the
// events on the timeline, e.g. in the snapshots viewer but
// mini-timeline showing all media).
const query = this._createMediaQueries('event');
if (query) {
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: { view: 'media', query: query },
queryExecutorOptions: {
selectResult: {
id: id,
// - If a folder media was loaded into the timeline from a prior folder
// query other than the one stored in the view (e.g. user navigated to
// a different folder in the thumbnails carousel).
if (item.query) {
// Item has a reference query (e.g. folders), use that.
const media = this._source.dataset
.get({
filter: (timelineItem) => item.query === timelineItem.query,
})
.map((timelineItem) => timelineItem.media)
.filter(isTruthy);
const selectedIndex = media.findIndex((m) => m.getID() === id);
if (selectedIndex >= 0) {
const queryResults = new QueryResults({ results: media, selectedIndex });
this._viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
query: item.query,
queryResults,
},
rejectResults: (results) => !results.hasResults(),
},
modifiers: [new MergeContextViewModifier(context)],
});
modifiers: [new MergeContextViewModifier(context)],
});
}
} else {
const currentQueryType =
QueryClassifier.getQueryType(view.query) ??
this._source.getKeyType() === 'camera'
? 'event'
: this._source.getKeyType() === 'folder'
? 'folder'
: null;
const query = currentQueryType ? this._createQuery(currentQueryType) : null;
if (query) {
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: { view: 'media', query: query },
queryExecutorOptions: {
selectResult: {
id,
},
rejectResults: (results) => !results.hasResults(),
},
modifiers: [new MergeContextViewModifier(context)],
});
}
}
} else {
this._viewManagerEpoch.manager.setViewByParameters({
@@ -600,12 +643,12 @@ export class TimelineController {
};
}
private _createMediaQueries(
private _createQuery(
type: QueryType,
options?: {
window?: TimelineWindow;
},
): MediaQueries | null {
): Query | null {
if (!this._timeline || !this._source) {
return null;
}
@@ -620,6 +663,9 @@ export class TimelineController {
} else if (type === 'recording') {
const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow);
return queries ? new RecordingMediaQuery(queries) : null;
} else if (type === 'folder') {
const queries = this._source.getTimelineFolderQuery();
return queries ? new FolderViewQuery(queries) : null;
}
return null;
}
@@ -644,20 +690,22 @@ export class TimelineController {
return;
}
await this._source?.refresh(this._getPrefetchWindow(properties));
await this._source?.refresh(this._getPrefetchWindow(properties), {
view,
});
const queryType = QueryClassifier.getQueryType(view.query);
if (!queryType) {
return;
}
const mediaQuery = this._createMediaQueries(queryType);
if (!mediaQuery || this._alreadyHasAcceptableMediaQuery(mediaQuery)) {
const query = this._createQuery(queryType);
if (!query || this._alreadyHasAcceptableMediaQuery(query)) {
return;
}
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: mediaQuery,
query,
},
queryExecutorOptions: {
selectResult: {
@@ -672,25 +720,27 @@ export class TimelineController {
});
};
private _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean {
private _alreadyHasAcceptableMediaQuery(freshQuery: Query): boolean {
const view = this._viewManagerEpoch?.manager.getView();
const query = view?.query;
if (!this._cameraManager || !query || !QueryClassifier.isMediaQuery(query)) {
if (!this._cameraManager || !query) {
return false;
}
const currentQueries = query?.getQuery();
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
return (
!!currentQueries &&
!!query?.getQuery() &&
!!currentResultTimestamp &&
!!query?.isSupersetOf(freshMediaQuery) &&
this._cameraManager.areMediaQueriesResultsFresh<MediaQuery>(
currentQueries,
currentResultTimestamp,
)
((QueryClassifier.isFolderQuery(query) && query.isEqual(freshQuery)) ||
(QueryClassifier.isMediaQuery(query) &&
QueryClassifier.isMediaQuery(freshQuery) &&
query.isSupersetOf(freshQuery) &&
this._cameraManager.areMediaQueriesResultsFresh<MediaQuery>(
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,
});
+113 -40
View File
@@ -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);
groups.push({
id: folderID,
content: key.folder.title ?? folderID,
});
}
});
} else if (keys.type === 'folder') {
const folderID = this._getGroupIDForFolder(keys.folder);
groups.push({
id: folderID,
content: keys.folder?.title ?? folderID,
});
}
return new DataSet(groups);
}
get groups(): DataSet<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,
+9 -9
View File
@@ -21,15 +21,15 @@ export interface ThumbnailDataRequest {
export class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
interface CameraTimelineKey {
type: 'camera';
cameraID: string;
}
interface FolderTimelineKey {
type: 'folder';
folder: FolderConfig;
}
export type TimelineKey = CameraTimelineKey | FolderTimelineKey;
export type TimelineKeys =
| {
type: 'camera';
cameraIDs: Set<string>;
}
| {
type: 'folder';
folder: FolderConfig;
};
export interface ExtendedTimeline extends Timeline {
// setCustomTimeMarker currently missing from Timeline types.