Skip to content

Commit

Permalink
perf: Change media fetches to be asynchronous to view render (#1702)
Browse files Browse the repository at this point in the history
This is a fairly non-trivial change in terms of consequence, so a greater than average chance something breaks. This is necessary since some cameras (e.g. Reolink) are materially slower to fetch media, and this change substantially improves card responsiveness.
  • Loading branch information
dermotduffy authored Dec 2, 2024
1 parent c69fdff commit 9ac6134
Show file tree
Hide file tree
Showing 23 changed files with 1,480 additions and 820 deletions.
2 changes: 2 additions & 0 deletions src/camera-manager/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,8 @@ export class CameraManager {
_queries,
', Results:',
results,
', Options:',
engineOptions ?? {},
']',
);
return results;
Expand Down
248 changes: 8 additions & 240 deletions src/card-controller/view/factory.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,21 @@
import { sub } from 'date-fns';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
FrigateCardView,
ViewDisplayMode,
} from '../../config/types';
import { localize } from '../../localize/localize';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { View, ViewParameters } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { CardViewAPI } from '../types';
import { QueryExecutor } from './query-executor';
import {
QueryExecutorOptions,
QueryWithResults,
ViewFactoryOptions,
ViewIncompatible,
ViewNoCameraError,
} from './types';
import { applyViewModifiers } from './modifiers';
import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types';

export class ViewFactory {
protected _api: CardViewAPI;
protected _executor: QueryExecutor;

constructor(api: CardViewAPI, executor?: QueryExecutor) {
constructor(api: CardViewAPI) {
this._api = api;
this._executor = executor ?? new QueryExecutor(api);
}

public getViewDefault(options?: ViewFactoryOptions): View | null {
Expand Down Expand Up @@ -91,7 +80,7 @@ export class ViewFactory {
);

// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
cameraID = viewCameraIDs.keys().next().value ?? null;
}

if (!cameraID) {
Expand Down Expand Up @@ -149,250 +138,29 @@ export class ViewFactory {
? options.baseView.evolve(viewParameters)
: new View(viewParameters);

if (options?.modifiers) {
options.modifiers.forEach((modifier) => modifier.modify(view));
}
return view;
}
applyViewModifiers(view, options?.modifiers);

public async getViewDefaultWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewDefault(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}

public async getViewByParametersWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewByParameters(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}

public async getViewByParametersWithExistingQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
const view = this.getViewByParameters(options);
if (view?.query) {
view.queryResults = await this._executor.execute(
view.query,
options?.queryExecutorOptions,
);
}
return view;
}

protected async _executeNewQuery(
view: View | null,
options?: ViewFactoryOptions,
): Promise<View | null> {
const config = this._api.getConfigManager().getConfig();
if (
!config ||
/* istanbul ignore next: this path cannot be reached as the only way for
view to be null here, is if the config is also null -- @preserve */
!view
) {
return null;
}

const executeMediaQuery = async (
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
): Promise<boolean> => {
/* istanbul ignore if: this path cannot be reached -- @preserve */
if (!mediaType) {
return false;
}
return await this._executeMediaQuery(
view,
mediaType === 'recordings' ? 'recordings' : 'events',
{
eventsMediaType: mediaType === 'recordings' ? undefined : mediaType,
executorOptions: options?.queryExecutorOptions,
},
);
};

// Implementation note: For new queries, if the query itself fails that is
// just ignored and the view is returned anyway (e.g. if the user changes to
// live but the thumbnail fetch fails, it is better to change to live and
// show no thumbnails than not change to live).
const mediaType = view.getDefaultMediaType();
const baseView = options?.baseView;
const switchingToGalleryFromViewer =
baseView?.isViewerView() && view.isGalleryView();

const alreadyHasMatchingQuery =
mediaType === MediaQueriesClassifier.getMediaType(baseView?.query);

if (
switchingToGalleryFromViewer &&
alreadyHasMatchingQuery &&
baseView?.query &&
baseView?.queryResults
) {
// If the user is currently using the viewer, and then switches to the
// gallery we make an attempt to keep the query/queryResults the same so
// the gallery can be used to click back and forth to the viewer, and the
// selected media can be centered in the gallery. See the matching code in
// `updated()` in `gallery.ts`. We specifically must ensure that the new
// target media of the gallery (e.g. clips, snapshots or recordings) is
// equal to the queries that are currently used in the viewer.
//
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
view.query = baseView.query;
view.queryResults = baseView.queryResults;
} else {
switch (view.view) {
case 'live':
if (config.live.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(
config.live.controls.thumbnails.media_type === 'recordings'
? 'recordings'
: config.live.controls.thumbnails.events_media_type,
);
}
break;

case 'media':
// If the user is looking at media in the `media` view and then
// changes camera (via the menu) it should default to showing clips
// for the new camera.
if (baseView && view.camera !== baseView.camera) {
await executeMediaQuery('clips');
}
break;

// Gallery views:
case 'clips':
case 'snapshots':
case 'recordings':
await executeMediaQuery(mediaType);
break;

// Viewer views:
case 'clip':
case 'snapshot':
case 'recording':
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(mediaType);
}
break;
}
}

this._setOrRemoveTimelineWindow(view);
this._setOrRemoveSeekTime(
view,
options?.queryExecutorOptions?.selectResult?.time?.time,
);
return view;
}

protected _setOrRemoveTimelineWindow(view: View): void {
if (view.is('live')) {
// For live views, always force the timeline to now, regardless of
// presence or not of events.
const now = new Date();
const liveConfig = this._api.getConfigManager().getConfig()?.live;

/* istanbul ignore if: this if branch cannot be reached as if the config is
empty this function is never called -- @preserve */
if (!liveConfig) {
return;
}

view.mergeInContext({
// Force the window to start at the most recent time, not
// necessarily when the most recent event/recording was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
timeline: {
window: {
start: sub(now, {
seconds: liveConfig.controls.timeline.window_seconds,
}),
end: now,
},
},
});
} else {
// For non-live views stick to default timeline behavior (will select and
// scroll to event).
view.removeContextProperty('timeline', 'window');
}
}

protected _setOrRemoveSeekTime(view: View, time?: Date): void {
if (time) {
view.mergeInContext({
mediaViewer: {
seek: time,
},
});
} else {
view.removeContextProperty('mediaViewer', 'seek');
}
}

protected async _executeMediaQuery(
view: View,
mediaType: 'events' | 'recordings',
options?: {
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
},
): Promise<boolean> {
const queryWithResults: QueryWithResults | null =
mediaType === 'events'
? await this._executor.executeDefaultEventQuery({
...(!view.isGrid() && { cameraID: view.camera }),
eventsMediaType: options?.eventsMediaType,
executorOptions: options?.executorOptions,
})
: mediaType === 'recordings'
? await this._executor.executeDefaultRecordingQuery({
...(!view.isGrid() && { cameraID: view.camera }),
executorOptions: options?.executorOptions,
})
: /* istanbul ignore next -- @preserve */
null;
if (!queryWithResults) {
return false;
}

view.query = queryWithResults.query;
view.queryResults = queryWithResults.queryResults;
return true;
}

public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}

protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
config: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
mode = config.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
mode = config.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
Expand Down
9 changes: 9 additions & 0 deletions src/card-controller/view/modifiers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { View } from '../../../view/view';
import { ViewModifier } from '../types';

export const applyViewModifiers = (
view: View,
modifiers?: ViewModifier[] | null,
): void => {
modifiers?.forEach((modifier) => modifier.modify(view));
};
26 changes: 26 additions & 0 deletions src/card-controller/view/modifiers/set-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { MediaQueries } from '../../../view/media-queries';
import { MediaQueriesResults } from '../../../view/media-queries-results';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';

export class SetQueryViewModifier implements ViewModifier {
protected _query?: MediaQueries | null;
protected _queryResults?: MediaQueriesResults | null;

constructor(options?: {
query?: MediaQueries | null;
queryResults?: MediaQueriesResults | null;
}) {
this._query = options?.query;
this._queryResults = options?.queryResults;
}

public modify(view: View): void {
if (this._query !== undefined) {
view.query = this._query;
}
if (this._queryResults !== undefined) {
view.queryResults = this._queryResults;
}
}
}
6 changes: 3 additions & 3 deletions src/card-controller/view/query-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryWithResults } from './types';
import { QueryExecutorOptions, QueryExecutorResult } from './types';

export class QueryExecutor {
protected _api: CardViewAPI;
Expand All @@ -22,7 +22,7 @@ export class QueryExecutor {
cameraID?: string;
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
}): Promise<QueryExecutorResult | null> {
const capabilitySearch: CapabilitySearchOptions =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
Expand Down Expand Up @@ -61,7 +61,7 @@ export class QueryExecutor {
public async executeDefaultRecordingQuery(options?: {
cameraID?: string;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
}): Promise<QueryExecutorResult | null> {
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
Expand Down
4 changes: 2 additions & 2 deletions src/card-controller/view/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface QueryExecutorOptions {
useCache?: boolean;
}

export interface QueryWithResults {
export interface QueryExecutorResult {
query: MediaQueries;
queryResults: MediaQueriesResults;
}
Expand Down Expand Up @@ -73,7 +73,7 @@ export interface ViewManagerInterface {
setViewWithMergedContext(context: ViewContext | null): void;

isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
hasMajorMediaChange(oldView?: View | null): boolean;
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
}

export class ViewNoCameraError extends FrigateCardError {}
Expand Down
Loading

0 comments on commit 9ac6134

Please sign in to comment.