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:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
@@ -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;
+12 -12
View File
@@ -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 -1
View File
@@ -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);
}
+23 -1
View File
@@ -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();
}
};
}
+10
View File
@@ -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;
}
+11 -4
View File
@@ -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:
+30 -10
View File
@@ -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 })
);
}
}
+13 -3
View File
@@ -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);
}
+9 -2
View File
@@ -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();
}
}
}
+14 -1
View File
@@ -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
? [
{
+26 -10
View File
@@ -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,
},
});
}
}
}
+7
View File
@@ -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;
+6
View File
@@ -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;
}
-185
View File
@@ -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
);
}
}
+1 -6
View File
@@ -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;
+10 -11
View File
@@ -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 {
+149 -55
View File
@@ -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
);
}
}