feat: Add support for Frigate reviews / detections [initial PR] (#2315)
- Add support for Frigate reviews / detections. - Add support for GenAI metadata. - Significant internal refactor to more flexible "UnifiedQuery" to allow mixing cameras with simple metadata and review metadata (e.g. a timeline view of a Frigate camera with reviews, and a Reolink camera with simple metadata). - Add support for folder media as camera media. There are a few more PRs to commit prior to this going live, but commiting this for now due to the scale of the change. BREAKING CHANGE: `media_type` and `events_type` are retired under `live`, `viewer` and `timeline` configuration sections, instead media type is associated (once) with the camera under `media`.
This commit is contained in:
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
|
||||
let specificActions: Actions | undefined = undefined;
|
||||
if (view?.is('live')) {
|
||||
specificActions = config?.live.actions;
|
||||
} else if (view?.isMediaGalleryView()) {
|
||||
} else if (view?.isGalleryView()) {
|
||||
specificActions = config?.media_gallery?.actions;
|
||||
} else if (view?.isViewerView()) {
|
||||
specificActions = config?.media_viewer.actions;
|
||||
|
||||
@@ -21,26 +21,26 @@ export class BaseAction<T extends ActionConfig> implements Action {
|
||||
|
||||
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
|
||||
const hass = api.getHASSManager().getHASS();
|
||||
const action: ActionConfig = this._action;
|
||||
|
||||
return (
|
||||
(typeof this._action.confirmation === 'boolean' && this._action.confirmation) ||
|
||||
(typeof this._action.confirmation === 'object' &&
|
||||
(!this._action.confirmation.exemptions ||
|
||||
!this._action.confirmation.exemptions.some(
|
||||
(entry) => entry.user === hass?.user.id,
|
||||
)))
|
||||
(typeof action.confirmation === 'boolean' && action.confirmation) ||
|
||||
(typeof action.confirmation === 'object' &&
|
||||
(!action.confirmation.exemptions ||
|
||||
!action.confirmation.exemptions.some((entry) => entry.user === hass?.user.id)))
|
||||
);
|
||||
}
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
if (this._shouldSeekConfirmation(api)) {
|
||||
const actionName = isAdvancedCameraCardCustomAction(this._action)
|
||||
? this._action.advanced_camera_card_action
|
||||
: this._action.action;
|
||||
const action: ActionConfig = this._action;
|
||||
const baseAction = action.action;
|
||||
const actionName = isAdvancedCameraCardCustomAction(action)
|
||||
? action.advanced_camera_card_action
|
||||
: baseAction;
|
||||
const text =
|
||||
(typeof this._action.confirmation === 'object'
|
||||
? this._action.confirmation.text
|
||||
: null) ?? `${localize('actions.confirmation')}: ${actionName}`;
|
||||
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
|
||||
`${localize('actions.confirmation')}: ${actionName}`;
|
||||
if (!confirm(text)) {
|
||||
throw new ActionAbortError(localize('actions.abort'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MediaDetailsController } from '../../../components-lib/media/details-controller';
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const item = api.getViewManager().getView()?.queryResults?.getSelectedResult();
|
||||
if (!ViewItemClassifier.isMedia(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
controller.calculate(api.getCameraManager(), item);
|
||||
|
||||
api.getOverlayMessageManager().setMessage(
|
||||
controller.getMessage({
|
||||
hass: api.getHASSManager().getHASS() ?? undefined,
|
||||
viewItemManager: api.getViewItemManager(),
|
||||
viewManagerEpoch: api.getViewManager().getEpoch(),
|
||||
capabilities: api.getViewItemManager().getCapabilities(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const view = api.getViewManager().getView();
|
||||
const queryResults = view?.queryResults;
|
||||
const item = queryResults?.getSelectedResult();
|
||||
|
||||
if (!ViewItemClassifier.isReview(item) || !queryResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetReviewedState = this._action.reviewed ?? !item.isReviewed();
|
||||
|
||||
await api.getViewItemManager().reviewMedia(item, targetReviewedState);
|
||||
|
||||
// Clone the item to ensure Lit detects the change.
|
||||
// Test-case: Setting a media item reviewed via the menu, should update the
|
||||
// reviewed state in a thumbnail.
|
||||
const clonedItem = item.clone();
|
||||
clonedItem.setReviewed(targetReviewedState);
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
params: {
|
||||
queryResults: queryResults.clone().replaceItem(item, clonedItem),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import { CustomAction } from './actions/custom';
|
||||
import { DefaultAction } from './actions/default';
|
||||
import { DisplayModeSelectAction } from './actions/display-mode-select';
|
||||
import { DownloadAction } from './actions/download';
|
||||
import { ExpandAction } from './actions/expand';
|
||||
import { EffectAction } from './actions/effect';
|
||||
import { ExpandAction } from './actions/expand';
|
||||
import { FullscreenAction } from './actions/fullscreen';
|
||||
import { InfoAction } from './actions/info';
|
||||
import { InternalCallbackAction } from './actions/internal-callback';
|
||||
import { LogAction } from './actions/log';
|
||||
import { MediaPlayerAction } from './actions/media-player';
|
||||
@@ -33,6 +34,7 @@ import { PTZDigitalAction } from './actions/ptz-digital';
|
||||
import { PTZMultiAction } from './actions/ptz-multi';
|
||||
import { ReloadAction } from './actions/reload';
|
||||
import { ScreenshotAction } from './actions/screenshot';
|
||||
import { SetReviewAction } from './actions/set-review';
|
||||
import { SleepAction } from './actions/sleep';
|
||||
import { StatusBarAction } from './actions/status-bar';
|
||||
import { SubstreamOffAction } from './actions/substream-off';
|
||||
@@ -93,6 +95,8 @@ export class ActionFactory {
|
||||
case 'live':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
case 'review':
|
||||
case 'reviews':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'timeline':
|
||||
@@ -110,6 +114,8 @@ export class ActionFactory {
|
||||
return new ExpandAction(context, action, options?.config);
|
||||
case 'fullscreen':
|
||||
return new FullscreenAction(context, action, options?.config);
|
||||
case 'info':
|
||||
return new InfoAction(context, action, options?.config);
|
||||
case 'menu_toggle':
|
||||
return new MenuToggleAction(context, action, options?.config);
|
||||
case 'camera_select':
|
||||
@@ -156,6 +162,8 @@ export class ActionFactory {
|
||||
return new StatusBarAction(context, action, options?.config);
|
||||
case 'reload':
|
||||
return new ReloadAction(context, action, options?.config);
|
||||
case 'set_review':
|
||||
return new SetReviewAction(context, action, options?.config);
|
||||
case INTERNAL_CALLBACK_ACTION:
|
||||
return new InternalCallbackAction(context, action, options?.config);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ActionEventTarget } from '../action-handler-directive';
|
||||
import { isCardInPanel } from '../ha/panel';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
import { isBeingCasted } from '../utils/casting';
|
||||
import { CardMediaReviewEventTarget } from '../utils/review';
|
||||
import { ViewItem } from '../view/item';
|
||||
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
import { CardElementAPI } from './types';
|
||||
@@ -13,7 +15,8 @@ export type MenuToggleCallback = () => void;
|
||||
export type CardHTMLElement = LitElement &
|
||||
ReactiveControllerHost &
|
||||
ActionEventTarget &
|
||||
ActionExecutionRequestEventTarget;
|
||||
ActionExecutionRequestEventTarget &
|
||||
CardMediaReviewEventTarget;
|
||||
|
||||
export class CardElementManager {
|
||||
protected _api: CardElementAPI;
|
||||
@@ -120,6 +123,10 @@ export class CardElementManager {
|
||||
'advanced-camera-card:action:execution-request',
|
||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||
);
|
||||
this._element.addEventListener(
|
||||
'advanced-camera-card:media:reviewed',
|
||||
this._handleMediaReviewed,
|
||||
);
|
||||
|
||||
// Listen for HA `navigate` actions.
|
||||
// See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43
|
||||
@@ -198,6 +205,10 @@ export class CardElementManager {
|
||||
'advanced-camera-card:action:execution-request',
|
||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||
);
|
||||
this._element.removeEventListener(
|
||||
'advanced-camera-card:media:reviewed',
|
||||
this._handleMediaReviewed,
|
||||
);
|
||||
|
||||
window.removeEventListener(
|
||||
'location-changed',
|
||||
@@ -208,4 +219,15 @@ export class CardElementManager {
|
||||
this._api.getQueryStringManager().requestExecution,
|
||||
);
|
||||
}
|
||||
|
||||
protected _handleMediaReviewed = (ev: CustomEvent<ViewItem>): void => {
|
||||
// If the selected media item has a change of review status, update the card
|
||||
// (e.g. for the menu).
|
||||
if (
|
||||
this._api.getViewManager().getView()?.queryResults?.getSelectedResult() ===
|
||||
ev.detail
|
||||
) {
|
||||
this.update();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ import { InitializationManager } from './initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||
|
||||
import { MediaPlayerManager } from './media-player-manager';
|
||||
import { MessageManager } from './message-manager';
|
||||
import { MicrophoneManager } from './microphone-manager';
|
||||
import { OverlayMessageManager } from './overlay-message-manager';
|
||||
import { QueryStringManager } from './query-string-manager';
|
||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
@@ -56,6 +58,7 @@ import {
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
CardMicrophoneAPI,
|
||||
CardOverlayMessageAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -87,6 +90,7 @@ export class CardController
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
CardMicrophoneAPI,
|
||||
CardOverlayMessageAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -118,9 +122,11 @@ export class CardController
|
||||
protected _interactionManager = new InteractionManager(this);
|
||||
protected _keyboardStateManager = new KeyboardStateManager(this);
|
||||
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
||||
|
||||
protected _mediaPlayerManager = new MediaPlayerManager(this);
|
||||
protected _messageManager = new MessageManager(this);
|
||||
protected _microphoneManager = new MicrophoneManager(this);
|
||||
protected _overlayMessageManager = new OverlayMessageManager(this);
|
||||
protected _queryStringManager = new QueryStringManager(this);
|
||||
protected _statusBarItemManager = new StatusBarItemManager(this);
|
||||
protected _styleManager = new StyleManager(this);
|
||||
@@ -248,6 +254,10 @@ export class CardController
|
||||
this._microphoneManager = new MicrophoneManager(this);
|
||||
}
|
||||
|
||||
public getOverlayMessageManager(): OverlayMessageManager {
|
||||
return this._overlayMessageManager;
|
||||
}
|
||||
|
||||
public getQueryStringManager(): QueryStringManager {
|
||||
return this._queryStringManager;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,8 @@ export class FoldersExecutor {
|
||||
this._ha = engines?.ha ?? new HAFoldersEngine();
|
||||
}
|
||||
|
||||
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
||||
return (
|
||||
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
|
||||
);
|
||||
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
|
||||
return this._getFolderEngine(folder.type)?.getDefaultQueryParameters(folder) ?? null;
|
||||
}
|
||||
|
||||
public generateChildFolderQuery(
|
||||
@@ -79,6 +77,15 @@ export class FoldersExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
|
||||
return (
|
||||
this._getFolderEngine(query.folder.type)?.areResultsFresh(
|
||||
resultsTimestamp,
|
||||
query,
|
||||
) ?? true
|
||||
);
|
||||
}
|
||||
|
||||
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
|
||||
switch (type) {
|
||||
case folderTypeSchema.enum.ha:
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { sub } from 'date-fns';
|
||||
import { NonEmptyTuple } from 'type-fest';
|
||||
import { ConditionState } from '../../../conditions/types';
|
||||
import {
|
||||
FolderConfig,
|
||||
folderTypeSchema,
|
||||
HA_MEDIA_SOURCE_ROOT,
|
||||
HAFolderPathComponent,
|
||||
HAFolderConfig,
|
||||
HAFolderPathComponent,
|
||||
} from '../../../config/schema/folders';
|
||||
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
|
||||
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
|
||||
import {
|
||||
BrowseMedia,
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMediaCache,
|
||||
BrowseMediaMetadata,
|
||||
RichBrowseMedia,
|
||||
@@ -22,16 +23,18 @@ import {
|
||||
} from '../../../ha/browse-media/walker';
|
||||
import { getMediaDownloadPath } from '../../../ha/download';
|
||||
import { HomeAssistant } from '../../../ha/types';
|
||||
import { QuerySource } from '../../../query-source.js';
|
||||
import { Endpoint } from '../../../types';
|
||||
|
||||
import { ViewFolder, ViewItem } from '../../../view/item';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { ViewItemCapabilities } from '../../../view/types';
|
||||
import {
|
||||
DownloadHelpers,
|
||||
EngineOptions,
|
||||
FolderPathComponent,
|
||||
FolderQuery,
|
||||
FoldersEngine,
|
||||
FolderPathComponent,
|
||||
} from '../types';
|
||||
import { MediaMatcher } from './media-matcher';
|
||||
import { MetadataGenerator } from './metadata-generator.js';
|
||||
@@ -83,17 +86,18 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
|
||||
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
|
||||
if (folder.type !== folderTypeSchema.enum.ha) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
source: QuerySource.Folder,
|
||||
folder,
|
||||
path: this.getDefaultFolderPathComponents(folder.ha),
|
||||
path: this._getDefaultPathComponents(folder.ha),
|
||||
};
|
||||
}
|
||||
|
||||
private getDefaultFolderPathComponents(
|
||||
private _getDefaultPathComponents(
|
||||
haFolderConfig?: HAFolderConfig,
|
||||
): NonEmptyTuple<FolderPathComponent> {
|
||||
const shouldAddDefaultRoot =
|
||||
@@ -158,14 +162,21 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
targets: BrowseMediaTarget<BrowseMediaMetadata>[],
|
||||
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
||||
const nextComponent = pathComponents.shift();
|
||||
const limit = query.limit ?? null;
|
||||
|
||||
return [
|
||||
{
|
||||
targets,
|
||||
metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) =>
|
||||
metadataGenerator: (media, parent) =>
|
||||
this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers),
|
||||
|
||||
// At the final step (no nextComponent), apply limit via earlyExit.
|
||||
...(limit && {
|
||||
earlyExit: (media) => media.length >= limit,
|
||||
}),
|
||||
|
||||
...(nextComponent && {
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
matcher: (media) =>
|
||||
this._mediaMatcher.match(hass, media, {
|
||||
matchers: nextComponent.ha?.matchers,
|
||||
// Set foldersOnly to true if there are more stages in the path,
|
||||
@@ -187,9 +198,11 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
},
|
||||
);
|
||||
|
||||
return getViewItemsFromBrowseMediaArray(browseMedia, {
|
||||
const results = getViewItemsFromBrowseMediaArray(browseMedia, {
|
||||
folder: query.folder,
|
||||
path: query.path,
|
||||
});
|
||||
return query.limit ? results.slice(0, query.limit) : results;
|
||||
}
|
||||
|
||||
public generateChildFolderQuery(
|
||||
@@ -202,7 +215,7 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
}
|
||||
|
||||
// Get the full configured path to find parsers/matchers for this depth.
|
||||
const fullPath = this.getDefaultFolderPathComponents(query.folder.ha);
|
||||
const fullPath = this._getDefaultPathComponents(query.folder.ha);
|
||||
const nextConfiguredComponent = fullPath[query.path.length];
|
||||
|
||||
// Use the configured component's parsers/matchers if available, otherwise
|
||||
@@ -214,4 +227,11 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
path: [...query.path, { folder, ha }],
|
||||
};
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
|
||||
return (
|
||||
!!query &&
|
||||
resultsTimestamp >= sub(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { cloneDeep } from 'lodash-es';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||
import { Endpoint } from '../../types';
|
||||
import { getFolderID } from '../../utils/folder';
|
||||
import { ViewFolder, ViewItem } from '../../view/item';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
import { CardFoldersAPI } from '../types';
|
||||
@@ -26,7 +28,7 @@ export class FoldersManager {
|
||||
public addFolders(folders: FolderConfigWithoutID[]): void {
|
||||
for (const folder of folders) {
|
||||
const folderNumber = this._folders.size;
|
||||
const id = folder.id ?? `folder/${folderNumber.toString()}`;
|
||||
const id = getFolderID(folder, folderNumber);
|
||||
if (this._folders.has(id)) {
|
||||
throw new FolderInitializationError(
|
||||
localize('error.duplicate_folder_id'),
|
||||
@@ -57,9 +59,9 @@ export class FoldersManager {
|
||||
: this._folders.values().next().value ?? null;
|
||||
}
|
||||
|
||||
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
|
||||
public getDefaultQueryParameters(folder?: FolderConfig): FolderQuery | null {
|
||||
const _folder = folder ?? this.getFolder();
|
||||
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
|
||||
return _folder ? this._executor.getDefaultQueryParameters(_folder) : null;
|
||||
}
|
||||
|
||||
public generateChildFolderQuery(
|
||||
@@ -74,12 +76,20 @@ export class FoldersManager {
|
||||
conditionState?: ConditionState,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<ViewItem[] | null> {
|
||||
if (hasUnsupportedFilters(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
return hass
|
||||
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
|
||||
: null;
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
|
||||
return this._executor.areResultsFresh(resultsTimestamp, query);
|
||||
}
|
||||
|
||||
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||
return this._executor.getItemCapabilities(item);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConditionState } from '../../conditions/types';
|
||||
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
|
||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { BaseQuery, QueryFilters, QuerySource } from '../../query-source';
|
||||
import { Endpoint } from '../../types';
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
import { ViewFolder, ViewItem } from '../../view/item';
|
||||
@@ -30,12 +31,14 @@ export interface FolderPathComponent extends FolderPathComponentMetadata {
|
||||
folder?: ViewFolder;
|
||||
}
|
||||
|
||||
export interface FolderQuery {
|
||||
export interface FolderQuery extends BaseQuery, QueryFilters {
|
||||
source: QuerySource.Folder;
|
||||
folder: FolderConfig;
|
||||
|
||||
// A trail of paths to navigate back to the "root", with the last path being
|
||||
// the path that this query directly refers to.
|
||||
path: NonEmptyTuple<FolderPathComponent>;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ===============
|
||||
@@ -47,7 +50,8 @@ export interface DownloadHelpers {
|
||||
}
|
||||
|
||||
export interface FoldersEngine {
|
||||
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
|
||||
getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null;
|
||||
|
||||
generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null;
|
||||
|
||||
expandFolder(
|
||||
@@ -63,5 +67,8 @@ export interface FoldersEngine {
|
||||
item: ViewItem,
|
||||
options?: DownloadHelpers,
|
||||
): Promise<Endpoint | null>;
|
||||
|
||||
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
|
||||
|
||||
areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { OverlayMessage } from '../types';
|
||||
import { CardOverlayMessageAPI } from './types';
|
||||
|
||||
export class OverlayMessageManager {
|
||||
protected _message: OverlayMessage | null = null;
|
||||
protected _api: CardOverlayMessageAPI;
|
||||
|
||||
constructor(api: CardOverlayMessageAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public getMessage(): OverlayMessage | null {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
public hasMessage(): boolean {
|
||||
return this._message !== null;
|
||||
}
|
||||
|
||||
public setMessage(message: OverlayMessage): void {
|
||||
this._message = message;
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
if (this._message) {
|
||||
this._message = null;
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,12 @@ export class StatusBarItemManager {
|
||||
? options?.cameraManager?.getCameraMetadata(options?.view?.camera)
|
||||
: null;
|
||||
const engineIcon = cameraMetadata?.engineIcon ?? null;
|
||||
const selectedResult = options?.view?.queryResults?.getSelectedResult();
|
||||
const severity = selectedResult?.getSeverity() ?? null;
|
||||
const title = options?.view?.is('live')
|
||||
? cameraMetadata?.title ?? null
|
||||
: options?.view?.isViewerView()
|
||||
? options?.view.queryResults?.getSelectedResult()?.getTitle() ?? null
|
||||
? selectedResult?.getTitle() ?? null
|
||||
: null;
|
||||
const resolution = options?.mediaLoadedInfo
|
||||
? this._calculateResolution(options?.mediaLoadedInfo)
|
||||
@@ -60,6 +62,17 @@ export class StatusBarItemManager {
|
||||
: null;
|
||||
|
||||
return [
|
||||
...(severity
|
||||
? [
|
||||
{
|
||||
type: 'custom:advanced-camera-card-status-bar-icon' as const,
|
||||
icon: 'mdi:circle-medium',
|
||||
severity,
|
||||
...options?.statusConfig?.items.severity,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
...(title
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -104,14 +104,16 @@ export class TriggersManager {
|
||||
.actions.trigger;
|
||||
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
|
||||
|
||||
// If this is a high-fidelity event where we are certain about new media,
|
||||
// don't take action unless it's to change to live (Frigate engine may pump
|
||||
// out events where there's no new media to show). Other trigger actions
|
||||
// (e.g. media, update) do not make sense without having some new media.
|
||||
// Early exit guard: If this is a high-fidelity event where we are certain
|
||||
// about new media, don't take action unless it's to change to live (Frigate
|
||||
// engine may pump out events where there's no new media to show). Other
|
||||
// trigger actions (e.g. media, update) do not make sense without having
|
||||
// some new media.
|
||||
if (
|
||||
ev.fidelity === 'high' &&
|
||||
!ev.snapshot &&
|
||||
!ev.clip &&
|
||||
!ev.review &&
|
||||
!(
|
||||
triggerAction === 'live' ||
|
||||
(triggerAction === 'default' && defaultView === 'live')
|
||||
@@ -139,12 +141,26 @@ export class TriggersManager {
|
||||
},
|
||||
});
|
||||
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
|
||||
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||
params: {
|
||||
view: ev.clip ? 'clip' : 'snapshot',
|
||||
camera: ev.cameraID,
|
||||
},
|
||||
});
|
||||
// Choose the most appropriate media view based on what's available.
|
||||
// Priority: review > clip > snapshot
|
||||
const view = ev.review
|
||||
? 'review'
|
||||
: ev.clip
|
||||
? 'clip'
|
||||
: ev.snapshot
|
||||
? 'snapshot'
|
||||
: /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
|
||||
null;
|
||||
|
||||
/* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
|
||||
if (view) {
|
||||
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||
params: {
|
||||
view,
|
||||
camera: ev.cameraID,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { MediaLoadedInfoManager } from './media-info-manager';
|
||||
import type { MediaPlayerManager } from './media-player-manager';
|
||||
import type { MessageManager } from './message-manager';
|
||||
import type { MicrophoneManager } from './microphone-manager';
|
||||
import type { OverlayMessageManager } from './overlay-message-manager';
|
||||
import type { QueryStringManager } from './query-string-manager';
|
||||
import type { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import type { StyleManager } from './style-manager';
|
||||
@@ -52,6 +53,7 @@ export interface CardActionsAPI {
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getOverlayMessageManager(): OverlayMessageManager;
|
||||
getStatusBarItemManager(): StatusBarItemManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewItemManager(): ViewItemManager;
|
||||
@@ -149,6 +151,7 @@ export interface CardElementAPI {
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardExpandAPI {
|
||||
@@ -245,6 +248,10 @@ export interface CardMessageAPI {
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
}
|
||||
|
||||
export interface CardOverlayMessageAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
}
|
||||
|
||||
export interface CardMicrophoneAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
|
||||
@@ -54,6 +54,12 @@ export class ViewItemManager {
|
||||
}
|
||||
}
|
||||
|
||||
public async reviewMedia(item: ViewItem, reviewed: boolean): Promise<void> {
|
||||
if (ViewItemClassifier.isReview(item)) {
|
||||
return await this._api.getCameraManager().reviewMedia(item, reviewed);
|
||||
}
|
||||
}
|
||||
|
||||
private _getMediaSource(item: ViewItem): ViewMediaSource | null {
|
||||
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
|
||||
return ViewMediaSource.Camera;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Query } from '../../../view/query';
|
||||
import { QueryResults } from '../../../view/query-results';
|
||||
import { UnifiedQuery } from '../../../view/unified-query';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SetQueryViewModifier implements ViewModifier {
|
||||
protected _query?: Query | null;
|
||||
protected _query?: UnifiedQuery | null;
|
||||
protected _queryResults?: QueryResults | null;
|
||||
|
||||
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
|
||||
constructor(options?: {
|
||||
query?: UnifiedQuery | null;
|
||||
queryResults?: QueryResults | null;
|
||||
}) {
|
||||
this._query = options?.query;
|
||||
this._queryResults = options?.queryResults;
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { CapabilitySearchKeys, MediaQuery } from '../../camera-manager/types';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import {
|
||||
EventMediaQuery,
|
||||
FolderViewQuery,
|
||||
MediaQueries,
|
||||
Query,
|
||||
RecordingMediaQuery,
|
||||
} from '../../view/query';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { QueryExecutorOptions, QueryExecutorResult } from './types';
|
||||
|
||||
export class QueryExecutor {
|
||||
protected _api: CardViewAPI;
|
||||
|
||||
constructor(api: CardViewAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public async executeDefaultEventQuery(options?: {
|
||||
cameraID?: string;
|
||||
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
executorOptions?: QueryExecutorOptions;
|
||||
}): Promise<QueryExecutorResult | null> {
|
||||
const capabilitySearch: CapabilitySearchKeys =
|
||||
!options?.eventsMediaType || options?.eventsMediaType === 'all'
|
||||
? {
|
||||
anyCapabilities: ['clips', 'snapshots'],
|
||||
}
|
||||
: options.eventsMediaType;
|
||||
|
||||
const cameraManager = this._api.getCameraManager();
|
||||
const cameraIDs = options?.cameraID
|
||||
? cameraManager
|
||||
.getStore()
|
||||
.getAllDependentCameras(options.cameraID, capabilitySearch)
|
||||
: cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch);
|
||||
if (!cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
|
||||
limit: this._getChunkLimit(),
|
||||
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
|
||||
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
if (!rawQueries) {
|
||||
return null;
|
||||
}
|
||||
const queries = new EventMediaQuery(rawQueries);
|
||||
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||
}
|
||||
|
||||
public async executeDefaultRecordingQuery(options?: {
|
||||
cameraID?: string;
|
||||
executorOptions?: QueryExecutorOptions;
|
||||
}): Promise<QueryExecutorResult | null> {
|
||||
const cameraManager = this._api.getCameraManager();
|
||||
const cameraIDs = options?.cameraID
|
||||
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
|
||||
: cameraManager.getStore().getCameraIDsWithCapability('recordings');
|
||||
if (!cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
|
||||
limit: this._getChunkLimit(),
|
||||
});
|
||||
if (!rawQueries) {
|
||||
return null;
|
||||
}
|
||||
const queries = new RecordingMediaQuery(rawQueries);
|
||||
return await this.executeMediaQuery(queries, options?.executorOptions);
|
||||
}
|
||||
|
||||
public async executeQuery(
|
||||
query: Query,
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
/* istanbul ignore else: this path cannot be reached -- @preserve */
|
||||
if (QueryClassifier.isMediaQuery(query)) {
|
||||
return await this.executeMediaQuery(query, executorOptions);
|
||||
} else if (QueryClassifier.isFolderQuery(query)) {
|
||||
return await this._executeFolderQuery(query, executorOptions);
|
||||
}
|
||||
|
||||
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
return null;
|
||||
}
|
||||
|
||||
public async executeMediaQuery(
|
||||
query: MediaQueries,
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
const queries = query.getQuery();
|
||||
if (!queries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaArray = await this._api
|
||||
.getCameraManager()
|
||||
.executeMediaQueries<MediaQuery>(queries, {
|
||||
useCache: executorOptions?.useCache,
|
||||
});
|
||||
const queryResults = mediaArray
|
||||
? this._generateQueriesResults(mediaArray, executorOptions)
|
||||
: null;
|
||||
return queryResults ? { query, queryResults } : null;
|
||||
}
|
||||
|
||||
private _generateQueriesResults(
|
||||
itemArray: ViewItem[],
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): QueryResults | null {
|
||||
const queryResults = new QueryResults({ results: itemArray });
|
||||
if (executorOptions?.rejectResults?.(queryResults)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (executorOptions?.selectResult?.id) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
media.findIndex((m) => m.getID() === executorOptions.selectResult?.id),
|
||||
);
|
||||
} else if (executorOptions?.selectResult?.func) {
|
||||
queryResults.selectResultIfFound(executorOptions.selectResult.func);
|
||||
} else if (executorOptions?.selectResult?.time) {
|
||||
queryResults.selectBestResult((itemArray) =>
|
||||
findBestMediaTimeIndex(
|
||||
itemArray,
|
||||
executorOptions.selectResult?.time?.time as Date,
|
||||
executorOptions.selectResult?.time?.favorCameraID,
|
||||
),
|
||||
);
|
||||
}
|
||||
return queryResults;
|
||||
}
|
||||
|
||||
public async executeFolderQuery(
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
const folder = this._api.getFoldersManager().getFolder(executorOptions?.folder);
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = this._api.getFoldersManager().generateDefaultFolderQuery(folder);
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._executeFolderQuery(new FolderViewQuery(query), executorOptions);
|
||||
}
|
||||
|
||||
private async _executeFolderQuery(
|
||||
query: FolderViewQuery,
|
||||
executorOptions?: QueryExecutorOptions,
|
||||
): Promise<QueryExecutorResult | null> {
|
||||
const rawQuery = query.getQuery();
|
||||
if (!rawQuery) {
|
||||
return null;
|
||||
}
|
||||
const itemArray = await this._api
|
||||
.getFoldersManager()
|
||||
.expandFolder(rawQuery, this._api.getConditionStateManager().getState(), {
|
||||
useCache: executorOptions?.useCache,
|
||||
});
|
||||
|
||||
const queryResults = itemArray
|
||||
? this._generateQueriesResults(itemArray, executorOptions)
|
||||
: null;
|
||||
return queryResults ? { query, queryResults } : null;
|
||||
}
|
||||
|
||||
protected _getChunkLimit(): number {
|
||||
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
|
||||
return (
|
||||
cardWideConfig?.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ViewContext } from 'view';
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
import { ViewItem } from '../../view/item.js';
|
||||
import { QueryResults } from '../../view/query-results.js';
|
||||
import { Query } from '../../view/query.js';
|
||||
|
||||
import { View, ViewParameters } from '../../view/view.js';
|
||||
|
||||
export interface ViewModifier {
|
||||
@@ -26,11 +26,6 @@ export interface QueryExecutorOptions {
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryExecutorResult {
|
||||
query: Query;
|
||||
queryResults: QueryResults;
|
||||
}
|
||||
|
||||
export interface ViewFactoryOptions {
|
||||
// An existing view to evolve from.
|
||||
baseView?: View | null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../utils/substream';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { View } from '../../view/view';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardViewAPI } from '../types';
|
||||
@@ -234,19 +233,19 @@ export class ViewManager implements ViewManagerInterface {
|
||||
// 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.
|
||||
// selected media can be centered in the gallery.
|
||||
//
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
|
||||
|
||||
const switchingFromViewerToGallery =
|
||||
this._view?.isViewerView() && newView?.isMediaGalleryView();
|
||||
const newMediaType = newView?.getDefaultMediaType();
|
||||
const alreadyHasMatchingQuery =
|
||||
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
|
||||
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
|
||||
if (!this._view?.isViewerView() || !newView?.isGalleryView()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the viewer came from this gallery type. If they did, we preserve
|
||||
// the query/results to ensure consistency in navigation between media &
|
||||
// gallery.
|
||||
const originView = this._view?.context?.gallery?.originView;
|
||||
return originView === newView.view;
|
||||
}
|
||||
|
||||
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { sub } from 'date-fns';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
|
||||
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { MediaTypeSpec, UnifiedQueryBuilder } from '../../view/unified-query-builder';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { View } from '../../view/view';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { MergeContextViewModifier } from './modifiers/merge-context';
|
||||
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
|
||||
import { SetQueryViewModifier } from './modifiers/set-query';
|
||||
import { QueryExecutor } from './query-executor';
|
||||
import { QueryExecutorOptions, ViewModifier } from './types';
|
||||
|
||||
/**
|
||||
@@ -14,27 +18,42 @@ import { QueryExecutorOptions, ViewModifier } from './types';
|
||||
* and if a query is made as part of this view the result can be applied later.
|
||||
*/
|
||||
export class ViewQueryExecutor {
|
||||
protected _api: CardViewAPI;
|
||||
protected _executor: QueryExecutor;
|
||||
private _api: CardViewAPI;
|
||||
|
||||
constructor(api: CardViewAPI, executor?: QueryExecutor) {
|
||||
constructor(api: CardViewAPI) {
|
||||
this._api = api;
|
||||
this._executor = executor ?? new QueryExecutor(api);
|
||||
}
|
||||
|
||||
public async getExistingQueryModifiers(
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
): Promise<ViewModifier[] | null> {
|
||||
return view.query
|
||||
if (!view.query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runner = new UnifiedQueryRunner(
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
this._api.getConditionStateManager(),
|
||||
);
|
||||
|
||||
const items = await runner.execute(view.query, {
|
||||
useCache: queryExecutorOptions?.useCache,
|
||||
});
|
||||
|
||||
const queryResults = this._applyResultSelection(
|
||||
new QueryResults({ results: items }),
|
||||
queryExecutorOptions,
|
||||
);
|
||||
|
||||
return queryResults
|
||||
? [
|
||||
new SetQueryViewModifier({
|
||||
queryResults: (
|
||||
await this._executor.executeQuery(view.query, queryExecutorOptions)
|
||||
)?.queryResults,
|
||||
queryResults,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
: null;
|
||||
}
|
||||
|
||||
public async getNewQueryModifiers(
|
||||
@@ -47,7 +66,7 @@ export class ViewQueryExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
protected async _executeNewQuery(
|
||||
private async _executeNewQuery(
|
||||
view: View,
|
||||
queryExecutorOptions?: QueryExecutorOptions,
|
||||
): Promise<ViewModifier[] | null> {
|
||||
@@ -56,71 +75,111 @@ export class ViewQueryExecutor {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaType = view?.getDefaultMediaType();
|
||||
const viewModifiers: ViewModifier[] = [];
|
||||
const builder = new UnifiedQueryBuilder(
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
);
|
||||
const runner = new UnifiedQueryRunner(
|
||||
this._api.getCameraManager(),
|
||||
this._api.getFoldersManager(),
|
||||
this._api.getConditionStateManager(),
|
||||
);
|
||||
|
||||
const executeMediaQuery = async (
|
||||
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
|
||||
): Promise<ViewModifier[]> => {
|
||||
/* istanbul ignore if: this path cannot be reached -- @preserve */
|
||||
if (!mediaType) {
|
||||
const executeQuery = async (query: UnifiedQuery | null): Promise<ViewModifier[]> => {
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results =
|
||||
mediaType === 'recordings'
|
||||
? await this._executor.executeDefaultRecordingQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
executorOptions: queryExecutorOptions,
|
||||
})
|
||||
: mediaType === 'clips' || mediaType === 'snapshots' || mediaType === 'all'
|
||||
? await this._executor.executeDefaultEventQuery({
|
||||
...(!view.isGrid() && { cameraID: view.camera }),
|
||||
eventsMediaType: mediaType,
|
||||
executorOptions: queryExecutorOptions,
|
||||
})
|
||||
: /* istanbul ignore next -- @preserve */
|
||||
null;
|
||||
const items = await runner.execute(query, {
|
||||
useCache: queryExecutorOptions?.useCache,
|
||||
});
|
||||
|
||||
return results ? [new SetQueryViewModifier(results)] : [];
|
||||
return [
|
||||
new SetQueryViewModifier({
|
||||
query,
|
||||
queryResults: new QueryResults({ results: items }),
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
|
||||
const results = await this._executor.executeFolderQuery(queryExecutorOptions);
|
||||
return results ? [new SetQueryViewModifier(results)] : [];
|
||||
};
|
||||
const cameraForQuery = view.isGrid() ? undefined : view.camera;
|
||||
|
||||
switch (view.view) {
|
||||
case 'live':
|
||||
if (config.live.controls.thumbnails.mode !== 'none') {
|
||||
viewModifiers.push(
|
||||
...(await executeMediaQuery(
|
||||
config.live.controls.thumbnails.media_type === 'recordings'
|
||||
? 'recordings'
|
||||
: config.live.controls.thumbnails.events_media_type,
|
||||
)),
|
||||
);
|
||||
const defaultQuery = builder.buildDefaultCameraQuery(cameraForQuery);
|
||||
if (defaultQuery) {
|
||||
viewModifiers.push(...(await executeQuery(defaultQuery)));
|
||||
}
|
||||
}
|
||||
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.
|
||||
viewModifiers.push(...(await executeMediaQuery('clips')));
|
||||
case 'timeline':
|
||||
// Timeline view always queries all cameras with media capabilities.
|
||||
viewModifiers.push(...(await executeQuery(builder.buildDefaultCameraQuery())));
|
||||
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.
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.clips(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.snapshots(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
viewModifiers.push(...(await executeMediaQuery(mediaType)));
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'review':
|
||||
case 'reviews':
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildCameraMediaQuery(MediaTypeSpec.reviews(), {
|
||||
cameraID: cameraForQuery,
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'folder':
|
||||
case 'folders':
|
||||
viewModifiers.push(...(await executeFolderQuery()));
|
||||
viewModifiers.push(
|
||||
...(await executeQuery(
|
||||
builder.buildDefaultFolderQuery(queryExecutorOptions?.folder, {
|
||||
limit: this._getLimit(),
|
||||
}),
|
||||
)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -131,7 +190,7 @@ export class ViewQueryExecutor {
|
||||
return viewModifiers;
|
||||
}
|
||||
|
||||
protected _getTimelineWindowViewModifier(view: View): ViewModifier[] {
|
||||
private _getTimelineWindowViewModifier(view: View): ViewModifier[] {
|
||||
if (view.is('live')) {
|
||||
// For live views, always force the timeline to now, regardless of
|
||||
// presence or not of events.
|
||||
@@ -166,7 +225,7 @@ export class ViewQueryExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
protected _getSeekTimeModifier(time?: Date): ViewModifier[] {
|
||||
private _getSeekTimeModifier(time?: Date): ViewModifier[] {
|
||||
if (time) {
|
||||
return [
|
||||
new MergeContextViewModifier({
|
||||
@@ -179,4 +238,39 @@ export class ViewQueryExecutor {
|
||||
return [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')];
|
||||
}
|
||||
}
|
||||
|
||||
private _applyResultSelection(
|
||||
queryResults: QueryResults,
|
||||
options?: QueryExecutorOptions,
|
||||
): QueryResults | null {
|
||||
if (options?.rejectResults?.(queryResults)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeSelection = options?.selectResult?.time;
|
||||
if (options?.selectResult?.id) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
media.findIndex((m) => m.getID() === options.selectResult?.id),
|
||||
);
|
||||
} else if (options?.selectResult?.func) {
|
||||
queryResults.selectResultIfFound(options.selectResult.func);
|
||||
} else if (timeSelection) {
|
||||
queryResults.selectBestResult((itemArray) =>
|
||||
findBestMediaTimeIndex(
|
||||
itemArray,
|
||||
timeSelection.time,
|
||||
timeSelection.favorCameraID,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return queryResults;
|
||||
}
|
||||
|
||||
private _getLimit(): number {
|
||||
return (
|
||||
this._api.getConfigManager().getConfig()?.performance?.features
|
||||
?.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user