fix: Improve review media filtering (#2322)

This commit is contained in:
Dermot Duffy
2026-01-24 16:22:12 -08:00
committed by GitHub
parent fc32727860
commit f4e905a7fb
37 changed files with 884 additions and 253 deletions
+2 -1
View File
@@ -179,9 +179,10 @@ cameras:
``` ```
| Option | Default | Description | | Option | Default | Description |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See below for description of each. | | `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See below for description of each. |
| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). | | `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). |
| `reviewed` | `unreviewed` | Whether to filter the media based on review status. One of `unreviewed` (default, shows only unreviewed media), `reviewed` (shorts only reviewed media) or `all` (show regardless of whether reviewed or unreviewed). Only relevant when `type` is `reviews` or `auto`. |
| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md). | | `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md). |
### Media Types ### Media Types
+2
View File
@@ -99,6 +99,7 @@ live:
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | | `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. | | `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | | `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_info_control` | `true` | Whether to show the info ('i') control on each thumbnail. |
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. | | `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. | | `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. | | `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
@@ -221,6 +222,7 @@ live:
show_details: false show_details: false
show_download_control: false show_download_control: false
show_favorite_control: true show_favorite_control: true
show_info_control: true
show_review_control: true show_review_control: true
show_timeline_control: false show_timeline_control: false
mode: none mode: none
+2
View File
@@ -47,6 +47,7 @@ media_gallery:
| `show_details` | `false` | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | | `show_details` | `false` | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `false` | Whether to show the download control on each thumbnail. | | `show_download_control` | `false` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | | `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_info_control` | `true` | Whether to show the info ('i') control on each thumbnail. |
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. | | `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
| `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. | | `show_timeline_control` | `false` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the gallery. Must be >= `75` and <= `300`. | | `size` | `100` | The size of the thumbnails in the gallery. Must be >= `75` and <= `300`. |
@@ -65,6 +66,7 @@ media_gallery:
show_details: false show_details: false
show_download_control: false show_download_control: false
show_favorite_control: true show_favorite_control: true
show_info_control: true
show_review_control: true show_review_control: true
show_timeline_control: false show_timeline_control: false
actions: actions:
+8 -2
View File
@@ -46,15 +46,16 @@ menu:
| `ptz_home` | The `ptz_home` button allows easily returning the camera to default home position. | | `ptz_home` | The `ptz_home` button allows easily returning the camera to default home position. |
| `recordings` | The `recordings` view menu button: brings the user to the `recordings` view on tap and the most-recent `recording` view on hold. | | `recordings` | The `recordings` view menu button: brings the user to the `recordings` view on tap and the most-recent `recording` view on hold. |
| `screenshot` | The `screenshot` menu button: take a screenshot of the loaded media (e.g. a still from a video). | | `screenshot` | The `screenshot` menu button: take a screenshot of the loaded media (e.g. a still from a video). |
| `set_review` | The `set_review` button: toggle the review status of the media being displayed (e.g. mark it as reviewed or unreviewed). |
| `snapshots` | The `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. | | `snapshots` | The `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. |
| `timeline` | The `timeline` menu button: show the event timeline. | | `timeline` | The `timeline` menu button: show the event timeline. |
### Options for each button ### Options for each button
| Option | Default | Description | | Option | Default | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. | | `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. |
| `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode`, `ptz_home` and `info`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. | | `enabled` | `true` for `iris`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode`, `ptz_home` and `info`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `set_review`, `ptz_controls` | Whether or not to show the button. |
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). | | `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). |
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). | | `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |
| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. | | `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. |
@@ -179,6 +180,11 @@ menu:
enabled: true enabled: true
alignment: matching alignment: matching
icon: mdi:home icon: mdi:home
set_review:
priority: 50
enabled: false
alignment: matching
icon: mdi:check-circle
snapshots: snapshots:
priority: 50 priority: 50
enabled: true enabled: true
+2
View File
@@ -68,6 +68,7 @@ timeline:
| `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. | | `show_details` | `false` | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail. |
| `show_download_control` | `true` | Whether to show the download control on each thumbnail. | | `show_download_control` | `true` | Whether to show the download control on each thumbnail. |
| `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. | | `show_favorite_control` | `true` | Whether to show the favorite ('star') control on each thumbnail. |
| `show_info_control` | `true` | Whether to show the info ('i') control on each thumbnail. |
| `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. | | `show_review_control` | `true` | Whether to show the review ('check') control on each thumbnail. |
| `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. | | `show_timeline_control` | `true` | Whether to show the timeline ('target') control on each thumbnail. |
| `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. | | `size` | `100` | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `300`. |
@@ -91,6 +92,7 @@ timeline:
show_details: true show_details: true
show_download_control: true show_download_control: true
show_favorite_control: true show_favorite_control: true
show_info_control: true
show_review_control: true show_review_control: true
show_timeline_control: true show_timeline_control: true
``` ```
+35 -2
View File
@@ -1,6 +1,7 @@
import { add } from 'date-fns'; import { add } from 'date-fns';
import { cloneDeep, sum } from 'lodash-es'; import { cloneDeep, omit, sum } from 'lodash-es';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { EqualityMap } from '../cache/equality-map.js';
import { CardCameraAPI } from '../card-controller/types.js'; import { CardCameraAPI } from '../card-controller/types.js';
import { sortItems } from '../card-controller/view/sort.js'; import { sortItems } from '../card-controller/view/sort.js';
import { import {
@@ -475,6 +476,38 @@ export class CameraManager {
); );
} }
/**
* Merge compatible queries by combining cameraIDs for queries with identical
* properties (other than cameraIDs). This preserves multi-camera batching for
* engines like Frigate that support querying multiple cameras at once.
*/
protected _mergeCompatibleQueries<T extends CameraQuery>(queries: T[]): T[] {
if (queries.length <= 1) {
return queries;
}
type CameraLessQuery = Omit<T, 'cameraIDs'>;
// Compare queries ignoring the camera parameter.
const groups = new EqualityMap<CameraLessQuery, T>();
for (const query of queries) {
const key: CameraLessQuery = omit(query, 'cameraIDs');
const existing = groups.get(key);
if (existing) {
// Merge cameraIDs into the existing query
for (const id of query.cameraIDs) {
existing.cameraIDs.add(id);
}
} else {
// Clone with a new Set to avoid mutating the original
groups.set(key, { ...query, cameraIDs: new Set(query.cameraIDs) });
}
}
return Array.from(groups.values());
}
public async extendMediaQueries<T extends MediaQuery>( public async extendMediaQueries<T extends MediaQuery>(
queries: T[], queries: T[],
results: ViewItem[], results: ViewItem[],
@@ -691,7 +724,7 @@ export class CameraManager {
query: QT | QT[], query: QT | QT[],
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> { ): Promise<Map<QT, QueryReturnType<QT>>> {
const _queries = arrayify(query); const _queries = this._mergeCompatibleQueries(arrayify(query));
const results = new Map<QT, QueryReturnType<QT>>(); const results = new Map<QT, QueryReturnType<QT>>();
const queryStartTime = new Date(); const queryStartTime = new Date();
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
@@ -1,5 +1,7 @@
import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review'; import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review';
import { toggleReviewed } from '../../../utils/media-actions';
import { ViewItemClassifier } from '../../../view/item-classifier'; import { ViewItemClassifier } from '../../../view/item-classifier';
import { getReviewedQueryFilterFromQuery } from '../../../view/utils/query-filter';
import { CardActionsAPI } from '../../types'; import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base'; import { AdvancedCameraCardAction } from './base';
@@ -7,7 +9,8 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const view = api.getViewManager().getView(); const viewManager = api.getViewManager();
const view = viewManager.getView();
const queryResults = view?.queryResults; const queryResults = view?.queryResults;
const item = queryResults?.getSelectedResult(); const item = queryResults?.getSelectedResult();
@@ -15,20 +18,26 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
return; return;
} }
const targetReviewedState = this._action.reviewed ?? !item.isReviewed(); const targetReviewedState = this._action.reviewed;
if (targetReviewedState !== undefined && targetReviewedState === item.isReviewed()) {
return;
}
await api.getViewItemManager().reviewMedia(item, targetReviewedState); const results = await Promise.all([
toggleReviewed(
item,
api.getViewItemManager(),
viewManager.getEpoch(),
getReviewedQueryFilterFromQuery(view?.query, item),
),
api
.getEffectsControllerAPI()
?.startEffect('check', { duration: 0.4, fadeIn: false }),
]);
// Clone the item to ensure Lit detects the change. // Trigger UI update to refresh menu icon state
// Test-case: Setting a media item reviewed via the menu, should update the if (results[0]) {
// reviewed state in a thumbnail. api.getCardElementManager().update();
const clonedItem = item.clone(); }
clonedItem.setReviewed(targetReviewedState);
api.getViewManager().setViewByParameters({
params: {
queryResults: queryResults.clone().replaceItem(item, clonedItem),
},
});
} }
} }
@@ -3,7 +3,7 @@ import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
import { QueryResults } from '../../view/query-results'; import { QueryResults } from '../../view/query-results';
import { UnifiedQuery } from '../../view/unified-query'; import { UnifiedQuery } from '../../view/unified-query';
import { MediaTypeSpec, UnifiedQueryBuilder } from '../../view/unified-query-builder'; import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
import { UnifiedQueryRunner } from '../../view/unified-query-runner'; import { UnifiedQueryRunner } from '../../view/unified-query-runner';
import { View } from '../../view/view'; import { View } from '../../view/view';
import { CardViewAPI } from '../types'; import { CardViewAPI } from '../types';
@@ -126,8 +126,9 @@ export class ViewQueryExecutor {
case 'clips': case 'clips':
viewModifiers.push( viewModifiers.push(
...(await executeQuery( ...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.clips(), { builder.buildCameraMediaQuery('events', {
cameraID: cameraForQuery, cameraID: cameraForQuery,
eventsSubtype: 'clips',
limit: this._getLimit(), limit: this._getLimit(),
}), }),
)), )),
@@ -139,8 +140,9 @@ export class ViewQueryExecutor {
case 'snapshots': case 'snapshots':
viewModifiers.push( viewModifiers.push(
...(await executeQuery( ...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.snapshots(), { builder.buildCameraMediaQuery('events', {
cameraID: cameraForQuery, cameraID: cameraForQuery,
eventsSubtype: 'snapshots',
limit: this._getLimit(), limit: this._getLimit(),
}), }),
)), )),
@@ -151,7 +153,7 @@ export class ViewQueryExecutor {
case 'recordings': case 'recordings':
viewModifiers.push( viewModifiers.push(
...(await executeQuery( ...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), { builder.buildCameraMediaQuery('recordings', {
cameraID: cameraForQuery, cameraID: cameraForQuery,
limit: this._getLimit(), limit: this._getLimit(),
}), }),
@@ -163,7 +165,7 @@ export class ViewQueryExecutor {
case 'reviews': case 'reviews':
viewModifiers.push( viewModifiers.push(
...(await executeQuery( ...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.reviews(), { builder.buildCameraMediaQuery('reviews', {
cameraID: cameraForQuery, cameraID: cameraForQuery,
limit: this._getLimit(), limit: this._getLimit(),
}), }),
@@ -1,7 +1,12 @@
import { EffectName, EffectsControllerAPI } from '../../types'; import { EffectName, EffectsControllerAPI } from '../../types';
import { Timer } from '../../utils/timer';
import { EffectComponent, EffectModule, EffectOptions } from './types'; import { EffectComponent, EffectModule, EffectOptions } from './types';
const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = { const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = {
check: async () => {
const module = await import('../../components/effects/check');
return { default: module.AdvancedCameraCardEffectCheck };
},
fireworks: async () => { fireworks: async () => {
const module = await import('../../components/effects/fireworks'); const module = await import('../../components/effects/fireworks');
return { default: module.AdvancedCameraCardEffectFireworks }; return { default: module.AdvancedCameraCardEffectFireworks };
@@ -29,6 +34,7 @@ type EffectsContainer = HTMLElement | DocumentFragment;
export class EffectsController implements EffectsControllerAPI { export class EffectsController implements EffectsControllerAPI {
private _importedModules: Map<EffectName, EffectModule> = new Map(); private _importedModules: Map<EffectName, EffectModule> = new Map();
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map(); private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
private _durationTimers: Map<EffectName, Timer> = new Map();
private _container: EffectsContainer | null = null; private _container: EffectsContainer | null = null;
public setContainer(container: EffectsContainer | null): void { public setContainer(container: EffectsContainer | null): void {
@@ -55,9 +61,28 @@ export class EffectsController implements EffectsControllerAPI {
effectComponent.fadeIn = options?.fadeIn ?? true; effectComponent.fadeIn = options?.fadeIn ?? true;
this._container.appendChild(effectComponent); this._container.appendChild(effectComponent);
this._activeInstances.set(name, effectComponent); this._activeInstances.set(name, effectComponent);
const duration = options?.duration;
if (duration !== undefined) {
return new Promise<void>((resolve) => {
const timer = new Timer();
this._durationTimers.set(name, timer);
timer.start(duration, async () => {
this._durationTimers.delete(name);
await this.stopEffect(name);
resolve();
});
});
}
} }
public async stopEffect(effect: EffectName): Promise<void> { public async stopEffect(effect: EffectName): Promise<void> {
const timer = this._durationTimers.get(effect);
if (timer) {
timer.stop();
this._durationTimers.delete(effect);
}
if (!this._activeInstances.has(effect)) { if (!this._activeInstances.has(effect)) {
return; return;
} }
+1
View File
@@ -7,4 +7,5 @@ export type EffectModule = { default: new () => EffectComponent };
export interface EffectOptions { export interface EffectOptions {
fadeIn?: boolean; fadeIn?: boolean;
duration?: number;
} }
+13 -4
View File
@@ -22,6 +22,10 @@ export interface OverlayControlsContext {
viewItemManager?: ViewItemManager; viewItemManager?: ViewItemManager;
viewManagerEpoch?: ViewManagerEpoch; viewManagerEpoch?: ViewManagerEpoch;
capabilities?: ViewItemCapabilities | null; capabilities?: ViewItemCapabilities | null;
// Whether to filter reviewed/unreviewed items after changing the reviewed
// state.
filterReviewed?: boolean;
} }
export class MediaDetailsController { export class MediaDetailsController {
@@ -231,7 +235,12 @@ export class MediaDetailsController {
: localize('common.set_reviews.reviewed'), : localize('common.set_reviews.reviewed'),
icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' }, icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' },
callback: async () => { callback: async () => {
const success = await toggleReviewed(item, context); const success = await toggleReviewed(
item,
context.viewItemManager,
context.viewManagerEpoch,
context.filterReviewed,
);
return success ? this.getMessage(context) : null; return success ? this.getMessage(context) : null;
}, },
}); });
@@ -244,7 +253,7 @@ export class MediaDetailsController {
icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' }, icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' },
emphasis: isFavorite ? 'medium' : undefined, emphasis: isFavorite ? 'medium' : undefined,
callback: async () => { callback: async () => {
const success = await toggleFavorite(item, context); const success = await toggleFavorite(item, context.viewItemManager);
return success ? this.getMessage(context) : null; return success ? this.getMessage(context) : null;
}, },
}); });
@@ -255,7 +264,7 @@ export class MediaDetailsController {
title: localize('thumbnail.download'), title: localize('thumbnail.download'),
icon: { icon: 'mdi:download' }, icon: { icon: 'mdi:download' },
callback: async () => { callback: async () => {
await downloadMedia(item, context); await downloadMedia(item, context.viewItemManager);
// Close overlay message after download. // Close overlay message after download.
return null; return null;
@@ -268,7 +277,7 @@ export class MediaDetailsController {
title: localize('thumbnail.timeline'), title: localize('thumbnail.timeline'),
icon: { icon: 'mdi:target' }, icon: { icon: 'mdi:target' },
callback: () => { callback: () => {
navigateToTimeline(item, context); navigateToTimeline(item, context.viewManagerEpoch);
// Close overlay after timeline navigation // Close overlay after timeline navigation
return null; return null;
+4 -1
View File
@@ -432,7 +432,10 @@ export class MenuButtonController {
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
const selectedItem = view?.queryResults?.getSelectedResult(); const selectedItem = view?.queryResults?.getSelectedResult();
if (!ViewItemClassifier.isMedia(selectedItem)) { if (
!ViewItemClassifier.isMedia(selectedItem) ||
!(view?.isViewerView() || view?.isGalleryView() || view?.is('timeline'))
) {
return null; return null;
} }
return { return {
+24
View File
@@ -0,0 +1,24 @@
import { CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import checkCircleSVG from '../../images/check-circle.svg';
import checkStyle from '../../scss/check.scss';
import { BaseEffectComponent } from './base';
@customElement('advanced-camera-card-effect-check')
export class AdvancedCameraCardEffectCheck extends BaseEffectComponent {
protected render(): TemplateResult {
// Using inline SVG to avoid ha-icon lazy-loading delay on first use.
return html`<span class="check">${unsafeHTML(checkCircleSVG)}</span>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(checkStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-effect-check': AdvancedCameraCardEffectCheck;
}
}
+4 -3
View File
@@ -34,6 +34,7 @@ import { ViewItemClassifier } from '../../view/item-classifier.js';
import { ViewFolder, ViewItem } from '../../view/item.js'; import { ViewFolder, ViewItem } from '../../view/item.js';
import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js'; import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js';
import { UnifiedQueryRunner } from '../../view/unified-query-runner.js'; import { UnifiedQueryRunner } from '../../view/unified-query-runner.js';
import { getReviewedQueryFilterFromQuery } from '../../view/utils/query-filter.js';
import '../media-filter.js'; import '../media-filter.js';
import '../message.js'; import '../message.js';
import { renderMessage } from '../message.js'; import { renderMessage } from '../message.js';
@@ -133,9 +134,8 @@ export class AdvancedCameraCardGallery extends LitElement {
} }
protected _renderThumbnails(): TemplateResult | void { protected _renderThumbnails(): TemplateResult | void {
const selected = this.viewManagerEpoch?.manager const view = this.viewManagerEpoch?.manager.getView();
.getView() const selected = view?.queryResults?.getSelectedResult();
?.queryResults?.getSelectedResult();
return html` return html`
${this._controller.getItems()?.map( ${this._controller.getItems()?.map(
@@ -161,6 +161,7 @@ export class AdvancedCameraCardGallery extends LitElement {
.show_review_control} .show_review_control}
?show_info_control=${!!this.galleryConfig?.controls.thumbnails ?show_info_control=${!!this.galleryConfig?.controls.thumbnails
.show_info_control} .show_info_control}
.filterReviewed=${getReviewedQueryFilterFromQuery(view?.query, item)}
@click=${(ev: Event) => { @click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
if (ViewItemClassifier.isMedia(item) && this.viewManagerEpoch) { if (ViewItemClassifier.isMedia(item) && this.viewManagerEpoch) {
+4
View File
@@ -31,6 +31,7 @@ import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-
import { ViewItemClassifier } from '../view/item-classifier.js'; import { ViewItemClassifier } from '../view/item-classifier.js';
import { ViewItem, ViewMedia } from '../view/item.js'; import { ViewItem, ViewMedia } from '../view/item.js';
import { UnifiedQueryBuilder } from '../view/unified-query-builder.js'; import { UnifiedQueryBuilder } from '../view/unified-query-builder.js';
import { getReviewedQueryFilterFromQuery } from '../view/utils/query-filter.js';
import './carousel.js'; import './carousel.js';
import './thumbnail/thumbnail.js'; import './thumbnail/thumbnail.js';
@@ -151,6 +152,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
selected: boolean, selected: boolean,
clickCallback: (item: ViewItem, ev: Event) => void, clickCallback: (item: ViewItem, ev: Event) => void,
seekTarget?: Date, seekTarget?: Date,
filterReviewed?: boolean,
): TemplateResult { ): TemplateResult {
const classes = { const classes = {
embla__slide: true, embla__slide: true,
@@ -161,6 +163,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
class="${classMap(classes)}" class="${classMap(classes)}"
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.hass=${this.hass} .hass=${this.hass}
.filterReviewed=${filterReviewed}
.item=${item} .item=${item}
.viewManagerEpoch=${this.viewManagerEpoch} .viewManagerEpoch=${this.viewManagerEpoch}
.viewItemManager=${this.viewItemManager} .viewItemManager=${this.viewItemManager}
@@ -210,6 +213,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
selectedIndex === thumbnails.length, selectedIndex === thumbnails.length,
clickHandler, clickHandler,
view?.context?.mediaViewer?.seek, view?.context?.mediaViewer?.seek,
getReviewedQueryFilterFromQuery(view?.query, item),
), ),
); );
} }
+13 -4
View File
@@ -67,6 +67,9 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
@property({ attribute: true, type: Boolean }) @property({ attribute: true, type: Boolean })
public show_info_control = false; public show_info_control = false;
@property({ attribute: false })
public filterReviewed?: boolean;
private _controller = new ThumbnailFeatureController(); private _controller = new ThumbnailFeatureController();
protected willUpdate(changedProperties: PropertyValues): void { protected willUpdate(changedProperties: PropertyValues): void {
@@ -83,6 +86,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
viewItemManager: this.viewItemManager, viewItemManager: this.viewItemManager,
viewManagerEpoch: this.viewManagerEpoch, viewManagerEpoch: this.viewManagerEpoch,
capabilities: this.item ? this.viewItemManager?.getCapabilities(this.item) : null, capabilities: this.item ? this.viewItemManager?.getCapabilities(this.item) : null,
filterReviewed: this.filterReviewed,
}; };
} }
@@ -164,7 +168,12 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
@click=${async (ev: Event) => { @click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
if (this.item) { if (this.item) {
await toggleReviewed(this.item, this._getControlContext()); await toggleReviewed(
this.item,
this.viewItemManager,
this.viewManagerEpoch,
this.filterReviewed,
);
} }
}} }}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
@@ -179,7 +188,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
if ( if (
this.item && this.item &&
(await toggleFavorite(this.item, this._getControlContext())) (await toggleFavorite(this.item, this.viewItemManager))
) { ) {
this.requestUpdate(); this.requestUpdate();
} }
@@ -210,7 +219,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
@click=${(ev: Event) => { @click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
if (this.item) { if (this.item) {
navigateToTimeline(this.item, this._getControlContext()); navigateToTimeline(this.item, this.viewManagerEpoch);
} }
}} }}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
@@ -223,7 +232,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
@click=${async (ev: Event) => { @click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
if (this.item) { if (this.item) {
await downloadMedia(this.item, this._getControlContext()); await downloadMedia(this.item, this.viewItemManager);
} }
}} }}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
+4
View File
@@ -55,6 +55,9 @@ export class AdvancedCameraCardThumbnail extends LitElement {
@property({ attribute: true, type: Boolean }) @property({ attribute: true, type: Boolean })
public show_info_control = false; public show_info_control = false;
@property({ attribute: false })
public filterReviewed?: boolean;
@property({ attribute: false }) @property({ attribute: false })
public seek?: Date; public seek?: Date;
@@ -80,6 +83,7 @@ export class AdvancedCameraCardThumbnail extends LitElement {
.show_download_control=${this.show_download_control} .show_download_control=${this.show_download_control}
.show_review_control=${this.show_review_control} .show_review_control=${this.show_review_control}
.show_info_control=${this.show_info_control} .show_info_control=${this.show_info_control}
.filterReviewed=${this.filterReviewed}
> >
</advanced-camera-card-thumbnail-feature> </advanced-camera-card-thumbnail-feature>
${this.details ${this.details
@@ -3,6 +3,7 @@ import { EffectName } from '../../../../types';
import { advancedCameraCardCustomActionsBaseSchema } from './base'; import { advancedCameraCardCustomActionsBaseSchema } from './base';
const effectNameSchema = z.enum([ const effectNameSchema = z.enum([
'check',
'fireworks', 'fireworks',
'ghost', 'ghost',
'hearts', 'hearts',
+7
View File
@@ -191,12 +191,19 @@ export type CameraMediaType = (typeof CAMERA_MEDIA_TYPES)[number];
const cameraMediaConfigDefault = { const cameraMediaConfigDefault = {
type: 'auto' as CameraMediaType, type: 'auto' as CameraMediaType,
reviewed: 'unreviewed' as CameraMediaReviewedFilter,
}; };
const CAMERA_MEDIA_REVIEWED_FILTERS = ['unreviewed', 'reviewed', 'all'] as const;
export type CameraMediaReviewedFilter = (typeof CAMERA_MEDIA_REVIEWED_FILTERS)[number];
const cameraMediaConfigSchema = z.object({ const cameraMediaConfigSchema = z.object({
type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type), type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type),
events_type: eventsMediaTypeSchema.optional(), events_type: eventsMediaTypeSchema.optional(),
folders: z.array(z.string()).optional(), folders: z.array(z.string()).optional(),
reviewed: z
.enum(CAMERA_MEDIA_REVIEWED_FILTERS)
.default(cameraMediaConfigDefault.reviewed),
}); });
export const cameraConfigSchema = z export const cameraConfigSchema = z
+2
View File
@@ -116,6 +116,8 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION =
export const CONF_CAMERAS_ARRAY_MEDIA_TYPE = `${CONF_CAMERAS}.#.media.type` as const; export const CONF_CAMERAS_ARRAY_MEDIA_TYPE = `${CONF_CAMERAS}.#.media.type` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE = export const CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE =
`${CONF_CAMERAS}.#.media.events_type` as const; `${CONF_CAMERAS}.#.media.events_type` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_REVIEWED =
`${CONF_CAMERAS}.#.media.reviewed` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS = export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS =
`${CONF_CAMERAS}.#.media.folders` as const; `${CONF_CAMERAS}.#.media.folders` as const;
+41 -15
View File
@@ -73,6 +73,7 @@ import {
CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE, CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE,
CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, CONF_CAMERAS_ARRAY_MEDIA_FOLDERS,
CONF_CAMERAS_ARRAY_MEDIA_REVIEWED,
CONF_CAMERAS_ARRAY_MEDIA_TYPE, CONF_CAMERAS_ARRAY_MEDIA_TYPE,
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN, CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN, CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
@@ -598,6 +599,22 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
}, },
]; ];
protected _cameraMediaReviewedOptions: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'unreviewed',
label: localize('config.cameras.media.revieweds.unreviewed'),
},
{
value: 'reviewed',
label: localize('config.cameras.media.revieweds.reviewed'),
},
{
value: 'all',
label: localize('config.cameras.media.revieweds.all'),
},
];
protected _transitionEffects: EditorSelectOption[] = [ protected _transitionEffects: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'none', label: localize('config.media_viewer.transition_effects.none') }, { value: 'none', label: localize('config.media_viewer.transition_effects.none') },
@@ -2547,6 +2564,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
label: localize('config.cameras.media.events_type'), label: localize('config.cameras.media.events_type'),
}, },
)} )}
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_REVIEWED, cameraIndex),
this._cameraMediaReviewedOptions,
{
label: localize('config.cameras.media.reviewed'),
},
)}
${this._renderOptionSelector( ${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, cameraIndex), getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, cameraIndex),
folderOptions, folderOptions,
@@ -2895,20 +2919,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
min: BUTTON_SIZE_MIN, min: BUTTON_SIZE_MIN,
})} })}
${this._renderMenuButton('iris') /* */} ${this._renderMenuButton('iris') /* */}
${this._renderMenuButton('cameras') /* */}
${this._renderMenuButton('substreams') /* */}
${this._renderMenuButton('live') /* */}
${this._renderMenuButton('reviews') /* */}
${this._renderMenuButton('clips') /* */}
${this._renderMenuButton('snapshots')}
${this._renderMenuButton('recordings')}
${this._renderMenuButton('folders')}
${this._renderMenuButton('image') /* */}
${this._renderMenuButton('download')}
${this._renderMenuButton('camera_ui')} ${this._renderMenuButton('camera_ui')}
${this._renderMenuButton('fullscreen')} ${this._renderMenuButton('cameras') /* */}
${this._renderMenuButton('clips')}
${this._renderMenuButton('display_mode')}
${this._renderMenuButton('download') /* */}
${this._renderMenuButton('expand') /* */} ${this._renderMenuButton('expand') /* */}
${this._renderMenuButton('timeline')} ${this._renderMenuButton('folders')}
${this._renderMenuButton('fullscreen')}
${this._renderMenuButton('image') /* */}
${this._renderMenuButton('info') /* */}
${this._renderMenuButton('live')}
${this._renderMenuButton('media_player')} ${this._renderMenuButton('media_player')}
${this._renderMenuButton( ${this._renderMenuButton(
'microphone', 'microphone',
@@ -2918,12 +2939,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
{ label: localize('config.menu.buttons.type') }, { label: localize('config.menu.buttons.type') },
)}`, )}`,
)} )}
${this._renderMenuButton('play') /* */}
${this._renderMenuButton('mute') /* */} ${this._renderMenuButton('mute') /* */}
${this._renderMenuButton('screenshot')} ${this._renderMenuButton('play')}
${this._renderMenuButton('display_mode')}
${this._renderMenuButton('ptz_controls')} ${this._renderMenuButton('ptz_controls')}
${this._renderMenuButton('ptz_home')} ${this._renderMenuButton('ptz_home')}
${this._renderMenuButton('recordings')}
${this._renderMenuButton('reviews')}
${this._renderMenuButton('screenshot')}
${this._renderMenuButton('set_review')}
${this._renderMenuButton('snapshots')}
${this._renderMenuButton('substreams')}
${this._renderMenuButton('timeline')}
</div> </div>
` `
: ''} : ''}
+8
View File
@@ -29,3 +29,11 @@ $ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-o
- Outline addded manually - Outline addded manually
- Opacity adjustmented manually - Opacity adjustmented manually
## check-circle.svg
**Link**: https://pictogrammers.com/library/mdi/icon/check-circle/
**Description**: Check circle, included to avoid loading time.
**License**: https://www.apache.org/licenses/LICENSE-2.0
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

+6
View File
@@ -145,6 +145,12 @@
"editor_label": "Default Media", "editor_label": "Default Media",
"events_type": "Events media subtype", "events_type": "Events media subtype",
"folders": "Folder IDs", "folders": "Folder IDs",
"reviewed": "Review status filter",
"revieweds": {
"all": "All (both reviewed and unreviewed)",
"reviewed": "Only reviewed",
"unreviewed": "Only unreviewed"
},
"type": "Default media type" "type": "Default media type"
}, },
"motioneye": { "motioneye": {
+32
View File
@@ -0,0 +1,32 @@
:host {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 1.5s ease-in;
pointer-events: none;
}
.check {
width: 80px;
height: 80px;
color: var(--success-color, #4caf50);
filter: drop-shadow(0px 4px 6px rgba(0, 0, 0, 0.3));
animation: check-popup 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
@keyframes check-popup {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
+7 -1
View File
@@ -208,7 +208,13 @@ export const signedPathSchema = z.object({
}); });
export type SignedPath = z.infer<typeof signedPathSchema>; export type SignedPath = z.infer<typeof signedPathSchema>;
export type EffectName = 'fireworks' | 'ghost' | 'hearts' | 'shamrocks' | 'snow'; export type EffectName =
| 'check'
| 'fireworks'
| 'ghost'
| 'hearts'
| 'shamrocks'
| 'snow';
export interface EffectsControllerAPI { export interface EffectsControllerAPI {
startEffect(name: EffectName, options?: EffectOptions): Promise<void>; startEffect(name: EffectName, options?: EffectOptions): Promise<void>;
+28 -21
View File
@@ -5,51 +5,55 @@ import { ViewItem } from '../view/item';
import { ViewItemClassifier } from '../view/item-classifier'; import { ViewItemClassifier } from '../view/item-classifier';
import { errorToConsole } from './basic'; import { errorToConsole } from './basic';
interface MediaActionOptions {
viewItemManager?: ViewItemManager;
viewManagerEpoch?: ViewManagerEpoch;
}
export async function toggleReviewed( export async function toggleReviewed(
item: ViewItem, item: ViewItem,
options: MediaActionOptions, viewItemManager?: ViewItemManager,
viewManagerEpoch?: ViewManagerEpoch,
filterReviewed?: boolean,
): Promise<boolean> { ): Promise<boolean> {
if (!ViewItemClassifier.isReview(item) || !options.viewItemManager) { if (!ViewItemClassifier.isReview(item) || !viewItemManager) {
return false; return false;
} }
const newState = !item.isReviewed(); const newState = !item.isReviewed();
try { try {
await options.viewItemManager.reviewMedia(item, newState); await viewItemManager.reviewMedia(item, newState);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
return false; return false;
} }
item.setReviewed(newState); item.setReviewed(newState);
// Remove from view results // Only remove from query results if the new state conflicts with the filter:
const view = options.viewManagerEpoch?.manager.getView(); // - If filter is 'false' (unreviewed only) and we toggled TO reviewed → remove
// - If filter is 'true' (reviewed only) and we toggled TO unreviewed → remove
// - If filter is 'undefined' (both) → never remove
const shouldRemove = filterReviewed !== undefined && filterReviewed !== newState;
if (shouldRemove) {
const view = viewManagerEpoch?.manager.getView();
if (view?.queryResults) { if (view?.queryResults) {
options.viewManagerEpoch?.manager.setViewByParameters({ viewManagerEpoch?.manager.setViewByParameters({
params: { params: {
queryResults: view.queryResults.clone().removeItem(item), queryResults: view.queryResults.clone().removeItem(item),
}, },
}); });
} }
}
return true; return true;
} }
export async function toggleFavorite( export async function toggleFavorite(
item: ViewItem, item: ViewItem,
options: MediaActionOptions, viewItemManager?: ViewItemManager,
): Promise<boolean> { ): Promise<boolean> {
if (!ViewItemClassifier.isMedia(item) || !options.viewItemManager) { if (!ViewItemClassifier.isMedia(item) || !viewItemManager) {
return false; return false;
} }
const newState = !item.isFavorite(); const newState = !item.isFavorite();
try { try {
await options.viewItemManager.favorite(item, newState); await viewItemManager.favorite(item, newState);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
return false; return false;
@@ -59,14 +63,14 @@ export async function toggleFavorite(
export async function downloadMedia( export async function downloadMedia(
item: ViewItem, item: ViewItem,
options: MediaActionOptions, viewItemManager?: ViewItemManager,
): Promise<boolean> { ): Promise<boolean> {
if (!options.viewItemManager) { if (!viewItemManager) {
return false; return false;
} }
try { try {
await options.viewItemManager.download(item); await viewItemManager.download(item);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
return false; return false;
@@ -74,15 +78,18 @@ export async function downloadMedia(
return true; return true;
} }
export function navigateToTimeline(item: ViewItem, options: MediaActionOptions): void { export function navigateToTimeline(
if (!options.viewManagerEpoch) { item: ViewItem,
viewManagerEpoch?: ViewManagerEpoch,
): void {
if (!viewManagerEpoch) {
return; return;
} }
options.viewManagerEpoch.manager.setViewByParameters({ viewManagerEpoch.manager.setViewByParameters({
params: { params: {
view: 'timeline', view: 'timeline',
queryResults: options.viewManagerEpoch.manager queryResults: viewManagerEpoch.manager
.getView() .getView()
?.queryResults?.clone() ?.queryResults?.clone()
.selectResultIfFound((media) => media === item), .selectResultIfFound((media) => media === item),
+97 -85
View File
@@ -11,31 +11,26 @@ import { FoldersManager } from '../card-controller/folders/manager';
import { FolderPathComponent, FolderQuery } from '../card-controller/folders/types'; import { FolderPathComponent, FolderQuery } from '../card-controller/folders/types';
import { CameraMediaType } from '../config/schema/cameras'; import { CameraMediaType } from '../config/schema/cameras';
import { FolderConfig } from '../config/schema/folders'; import { FolderConfig } from '../config/schema/folders';
import { QuerySource } from '../query-source.js'; import { QueryFilters, QuerySource } from '../query-source.js';
import { VIEW_MEDIA_TYPES, ViewMediaType } from '../types'; import { VIEW_MEDIA_TYPES, ViewMediaType } from '../types';
import { arrayify } from '../utils/basic'; import { arrayify } from '../utils/basic';
import { QueryNode, UnifiedQuery } from '../view/unified-query'; import { QueryNode, UnifiedQuery } from '../view/unified-query';
import { getReviewedQueryFilterFromConfig } from './utils/query-filter';
interface MediaQueryBuildOptions { interface MediaQueryBuildOptions extends QueryFilters {
start?: Date; start?: Date;
end?: Date; end?: Date;
limit?: number; limit?: number;
} }
interface FilterQueryBuildOptions extends MediaQueryBuildOptions { interface QueryFiltersOptions extends QueryFilters {
favorite?: boolean;
tags?: Set<string>;
what?: Set<string>;
where?: Set<string>;
reviewed?: boolean;
}
interface QueryLimitOptions {
limit?: number; limit?: number;
} }
type MediaType = 'events' | 'recordings' | 'reviews' | 'folder';
export interface MediaTypeSpec { export interface MediaTypeSpec {
mediaType: 'events' | 'recordings' | 'reviews' | 'folder'; mediaType: MediaType;
eventsSubtype?: 'clips' | 'snapshots'; eventsSubtype?: 'clips' | 'snapshots';
} }
@@ -149,7 +144,7 @@ export class UnifiedQueryBuilder {
public buildReviewsQuery( public buildReviewsQuery(
cameraIDs: Set<string>, cameraIDs: Set<string>,
options?: MediaQueryBuildOptions & { reviewed?: boolean }, options?: MediaQueryBuildOptions,
): UnifiedQuery | null { ): UnifiedQuery | null {
const query = this._addNode( const query = this._addNode(
new UnifiedQuery(), new UnifiedQuery(),
@@ -160,11 +155,9 @@ export class UnifiedQueryBuilder {
private _buildReviewsQueryNode( private _buildReviewsQueryNode(
cameraIDs: Set<string>, cameraIDs: Set<string>,
options?: MediaQueryBuildOptions & { reviewed?: boolean }, options?: MediaQueryBuildOptions,
): ReviewQuery | null { ): ReviewQuery | null {
return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options, { return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options);
reviewed: options?.reviewed,
});
} }
private _buildBaseQueryNode( private _buildBaseQueryNode(
@@ -182,7 +175,6 @@ export class UnifiedQueryBuilder {
type: QueryType.Review, type: QueryType.Review,
cameraIDs: Set<string>, cameraIDs: Set<string>,
options?: MediaQueryBuildOptions, options?: MediaQueryBuildOptions,
extraProps?: { reviewed?: boolean },
): ReviewQuery | null; ): ReviewQuery | null;
private _buildBaseQueryNode( private _buildBaseQueryNode(
type: QueryType.Event | QueryType.Recording | QueryType.Review, type: QueryType.Event | QueryType.Recording | QueryType.Review,
@@ -200,6 +192,7 @@ export class UnifiedQueryBuilder {
cameraIDs, cameraIDs,
...this._mergeDefaultsForCameras(cameraIDs, type), ...this._mergeDefaultsForCameras(cameraIDs, type),
...this._extractCommonOptions(options), ...this._extractCommonOptions(options),
...this._extractFilterOptions(options),
...extraProps, ...extraProps,
}; };
} }
@@ -217,7 +210,7 @@ export class UnifiedQueryBuilder {
public buildFilterQuery( public buildFilterQuery(
cameraIDs: Set<string> | null, cameraIDs: Set<string> | null,
mediaTypes: Set<ViewMediaType> | null, mediaTypes: Set<ViewMediaType> | null,
options?: FilterQueryBuildOptions, options?: MediaQueryBuildOptions,
): UnifiedQuery | null { ): UnifiedQuery | null {
const query = new UnifiedQuery(); const query = new UnifiedQuery();
@@ -249,43 +242,21 @@ export class UnifiedQueryBuilder {
private _buildFilterQueryNode( private _buildFilterQueryNode(
mediaType: ViewMediaType, mediaType: ViewMediaType,
cameraIDs: Set<string>, cameraIDs: Set<string>,
options?: FilterQueryBuildOptions, options?: MediaQueryBuildOptions,
): EventQuery | RecordingQuery | ReviewQuery | null { ): EventQuery | RecordingQuery | ReviewQuery | null {
const filterProps = {
...(options?.favorite !== undefined && { favorite: options.favorite }),
...(options?.tags && { tags: options.tags }),
...(options?.what && { what: options.what }),
...(options?.where && { where: options.where }),
...(options?.reviewed !== undefined && { reviewed: options.reviewed }),
};
switch (mediaType) { switch (mediaType) {
case 'clips': { case 'clips':
const node = this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, { return this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, {
hasClip: true, hasClip: true,
}); });
/* istanbul ignore next: see class note on code coverage -- @preserve */ case 'snapshots':
return node ? { ...node, ...filterProps } : null; return this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, {
}
case 'snapshots': {
const node = this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, {
hasSnapshot: true, hasSnapshot: true,
}); });
/* istanbul ignore next: see class note on code coverage -- @preserve */ case 'recordings':
return node ? { ...node, ...filterProps } : null; return this._buildBaseQueryNode(QueryType.Recording, cameraIDs, options);
} case 'reviews':
case 'recordings': { return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options);
const node = this._buildBaseQueryNode(QueryType.Recording, cameraIDs, options);
/* istanbul ignore next: see class note on code coverage -- @preserve */
return node ? { ...node, ...filterProps } : null;
}
case 'reviews': {
const node = this._buildBaseQueryNode(QueryType.Review, cameraIDs, options, {
reviewed: options?.reviewed,
});
/* istanbul ignore next: see class note on code coverage -- @preserve */
return node ? { ...node, ...filterProps } : null;
}
} }
} }
@@ -296,7 +267,7 @@ export class UnifiedQueryBuilder {
public buildFolderQueryWithPath( public buildFolderQueryWithPath(
folder: FolderConfig, folder: FolderConfig,
path: NonEmptyTuple<FolderPathComponent>, path: NonEmptyTuple<FolderPathComponent>,
options?: QueryLimitOptions, options?: QueryFiltersOptions,
): UnifiedQuery { ): UnifiedQuery {
const query = new UnifiedQuery(); const query = new UnifiedQuery();
const folderQuery: FolderQuery = { const folderQuery: FolderQuery = {
@@ -311,7 +282,7 @@ export class UnifiedQueryBuilder {
public buildDefaultFolderQuery( public buildDefaultFolderQuery(
folderID?: string, folderID?: string,
options?: QueryLimitOptions, options?: QueryFiltersOptions,
): UnifiedQuery | null { ): UnifiedQuery | null {
const query = new UnifiedQuery(); const query = new UnifiedQuery();
this._addNode(query, this._buildFolderQueryNode(folderID, options)); this._addNode(query, this._buildFolderQueryNode(folderID, options));
@@ -320,7 +291,7 @@ export class UnifiedQueryBuilder {
private _buildFolderQueryNodesForCameras( private _buildFolderQueryNodesForCameras(
cameraIDs: Set<string>, cameraIDs: Set<string>,
options?: QueryLimitOptions, options?: QueryFiltersOptions,
): QueryNode[] { ): QueryNode[] {
const nodes: QueryNode[] = []; const nodes: QueryNode[] = [];
for (const cameraID of cameraIDs) { for (const cameraID of cameraIDs) {
@@ -339,7 +310,7 @@ export class UnifiedQueryBuilder {
private _buildFolderQueryNode( private _buildFolderQueryNode(
folderID?: string, folderID?: string,
options?: QueryLimitOptions, options?: QueryFiltersOptions,
): QueryNode | null { ): QueryNode | null {
const folder = this._foldersManager.getFolder(folderID); const folder = this._foldersManager.getFolder(folderID);
const params = folder && this._foldersManager.getDefaultQueryParameters(folder); const params = folder && this._foldersManager.getDefaultQueryParameters(folder);
@@ -357,7 +328,7 @@ export class UnifiedQueryBuilder {
public buildDefaultCameraQuery( public buildDefaultCameraQuery(
cameraID?: string, cameraID?: string,
options?: QueryLimitOptions, options?: QueryFiltersOptions,
): UnifiedQuery | null { ): UnifiedQuery | null {
const cameraIDs = cameraID const cameraIDs = cameraID
? this._cameraManager.getStore().getAllDependentCameras(cameraID) ? this._cameraManager.getStore().getAllDependentCameras(cameraID)
@@ -372,7 +343,7 @@ export class UnifiedQueryBuilder {
private _buildDefaultCameraQueryNodes( private _buildDefaultCameraQueryNodes(
cameraID: string, cameraID: string,
options?: QueryLimitOptions, options?: QueryFiltersOptions,
): QueryNode | QueryNode[] | null { ): QueryNode | QueryNode[] | null {
const mediaConfig = this._cameraManager.getStore().getCameraConfig(cameraID)?.media; const mediaConfig = this._cameraManager.getStore().getCameraConfig(cameraID)?.media;
const spec = this._resolveMediaTypeSpec( const spec = this._resolveMediaTypeSpec(
@@ -384,7 +355,7 @@ export class UnifiedQueryBuilder {
return null; return null;
} }
return this._buildQueryNodesCapabilityUnchecked(spec, new Set([cameraID]), { return this._buildQueryNodesCapabilityUnchecked(spec, cameraID, {
limit: options?.limit, limit: options?.limit,
}); });
} }
@@ -394,33 +365,23 @@ export class UnifiedQueryBuilder {
// ========================================================================= // =========================================================================
public buildCameraMediaQuery( public buildCameraMediaQuery(
spec: MediaTypeSpec, mediaType: MediaType,
options?: QueryLimitOptions & { options?: QueryFiltersOptions & {
cameraID?: string; cameraID?: string;
eventsSubtype?: 'clips' | 'snapshots';
}, },
): UnifiedQuery | null { ): UnifiedQuery | null {
let neededCapability: CapabilitySearchKeys; if (mediaType === 'folder') {
switch (spec.mediaType) {
case 'events':
switch (spec.eventsSubtype) {
case 'clips':
case 'snapshots':
neededCapability = spec.eventsSubtype;
break;
default:
neededCapability = { anyCapabilities: ['clips', 'snapshots'] };
break;
}
break;
case 'recordings':
case 'reviews':
neededCapability = spec.mediaType;
break;
case 'folder':
// Folders are handled separately by buildDefaultFolderQuery. // Folders are handled separately by buildDefaultFolderQuery.
return null; return null;
} }
// Map the simple media type to the capability required
const neededCapability: CapabilitySearchKeys =
mediaType === 'events'
? options?.eventsSubtype ?? { anyCapabilities: ['clips', 'snapshots'] }
: mediaType;
const cameraIDs = options?.cameraID const cameraIDs = options?.cameraID
? this._cameraManager ? this._cameraManager
.getStore() .getStore()
@@ -428,10 +389,33 @@ export class UnifiedQueryBuilder {
: this._cameraManager.getStore().getCameraIDsWithCapability(neededCapability); : this._cameraManager.getStore().getCameraIDsWithCapability(neededCapability);
const query = new UnifiedQuery(); const query = new UnifiedQuery();
for (const cameraID of cameraIDs) {
const cameraSpec = this._resolveMediaTypeSpec(
cameraID,
mediaType,
options?.eventsSubtype,
);
if (!cameraSpec) {
continue;
}
// For reviews, resolve the reviewed filter from camera config if not provided
const resolvedOptions =
cameraSpec.mediaType === 'reviews' && options?.reviewed === undefined
? {
...options,
reviewed: getReviewedQueryFilterFromConfig(
this._cameraManager.getStore().getCameraConfig(cameraID)?.media
?.reviewed,
),
}
: options;
this._addNode( this._addNode(
query, query,
this._buildQueryNodesCapabilityUnchecked(spec, cameraIDs, options), this._buildQueryNodesCapabilityUnchecked(cameraSpec, cameraID, resolvedOptions),
); );
}
return query.hasNodes() ? query : null; return query.hasNodes() ? query : null;
} }
@@ -441,7 +425,9 @@ export class UnifiedQueryBuilder {
eventsType?: 'clips' | 'snapshots' | 'all', eventsType?: 'clips' | 'snapshots' | 'all',
): MediaTypeSpec | null { ): MediaTypeSpec | null {
const capabilities = this._cameraManager.getCameraCapabilities(cameraID); const capabilities = this._cameraManager.getCameraCapabilities(cameraID);
if (!capabilities) { const config = this._cameraManager.getStore().getCameraConfig(cameraID);
if (!capabilities || !config) {
return null; return null;
} }
@@ -473,25 +459,41 @@ export class UnifiedQueryBuilder {
return hasReviews ? MediaTypeSpec.reviews() : null; return hasReviews ? MediaTypeSpec.reviews() : null;
case 'folder': case 'folder':
return MediaTypeSpec.folder(); return MediaTypeSpec.folder();
case 'events': case 'events': {
if (eventsType === 'all' && hasClips && hasSnapshots) { const configEventsType = eventsType ?? config.media?.events_type;
if (
(!configEventsType || configEventsType === 'all') &&
hasClips &&
hasSnapshots
) {
return MediaTypeSpec.events(); return MediaTypeSpec.events();
} }
if ((eventsType === 'all' || eventsType === 'clips') && hasClips) {
// Resolve to concrete type: prefer config, fallback to available capability
const targetType =
configEventsType === 'clips' || configEventsType === 'snapshots'
? configEventsType
: hasClips
? 'clips'
: 'snapshots';
if (targetType === 'clips' && hasClips) {
return MediaTypeSpec.clips(); return MediaTypeSpec.clips();
} }
if ((eventsType === 'all' || eventsType === 'snapshots') && hasSnapshots) { if (targetType === 'snapshots' && hasSnapshots) {
return MediaTypeSpec.snapshots(); return MediaTypeSpec.snapshots();
} }
return null; return null;
} }
} }
}
private _buildQueryNodesCapabilityUnchecked( private _buildQueryNodesCapabilityUnchecked(
spec: MediaTypeSpec, spec: MediaTypeSpec,
cameraIDs: Set<string>, cameraID: string,
options?: QueryLimitOptions, options?: QueryFiltersOptions,
): QueryNode | QueryNode[] | null { ): QueryNode | QueryNode[] | null {
const cameraIDs = new Set([cameraID]);
switch (spec.mediaType) { switch (spec.mediaType) {
case 'events': case 'events':
switch (spec.eventsSubtype) { switch (spec.eventsSubtype) {
@@ -528,6 +530,16 @@ export class UnifiedQueryBuilder {
}; };
} }
private _extractFilterOptions(options?: QueryFilters): QueryFilters {
return {
...(options?.favorite !== undefined && { favorite: options.favorite }),
...(options?.tags && { tags: options.tags }),
...(options?.what && { what: options.what }),
...(options?.where && { where: options.where }),
...(options?.reviewed !== undefined && { reviewed: options.reviewed }),
};
}
private _mergeDefaultsForCameras( private _mergeDefaultsForCameras(
cameraIDs: Set<string>, cameraIDs: Set<string>,
queryType: QueryType, queryType: QueryType,
+46
View File
@@ -0,0 +1,46 @@
import { CameraMediaReviewedFilter } from '../../config/schema/cameras';
import { ViewItem } from '../item';
import { ViewItemClassifier } from '../item-classifier';
import { UnifiedQuery } from '../unified-query';
/**
* Get the reviewed filter from a query for a specific item.
*
* This is used to determine whether toggling the reviewed status of a media
* item should remove it from the current results.
*
* @param query The query that produced the results.
* @param item The view item to get the filter for.
* @returns The reviewed filter (true = reviewed only, false = unreviewed only,
* undefined = both or ambiguous).
*/
export function getReviewedQueryFilterFromQuery(
query?: UnifiedQuery | null,
item?: ViewItem,
): boolean | undefined {
if (!query || !item || !ViewItemClassifier.isMedia(item)) {
return undefined;
}
const cameraID = item.getCameraID();
if (!cameraID) {
return undefined;
}
const mediaQueries = query.getMediaQueries({ cameraID });
// Only use the filter if there's exactly one matching query (unambiguous).
// If zero or multiple queries, return undefined (show all / no removal).
return mediaQueries.length === 1 ? mediaQueries[0].reviewed : undefined;
}
/**
* Convert a reviewed config value to a boolean filter.
* @param reviewed The config value ('reviewed', 'all', 'unreviewed' or undefined)
* @returns true (reviewed only), false (unreviewed only), or undefined (all)
*/
export function getReviewedQueryFilterFromConfig(
reviewed?: CameraMediaReviewedFilter,
): boolean | undefined {
return reviewed === 'reviewed' ? true : reviewed === 'all' ? undefined : false;
}
+104
View File
@@ -822,6 +822,110 @@ describe('CameraManager', () => {
expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]); expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]);
}); });
describe('should merge compatible queries', () => {
it('merges queries with identical properties', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine, [
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) },
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) },
]);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const query1: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam1']),
reviewed: false,
};
const query2: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam2']),
reviewed: false,
};
engine.getEvents.mockResolvedValue(new Map());
engine.generateMediaFromEvents.mockReturnValue([]);
await manager.executeMediaQueries([query1, query2]);
// Should be called once with merged cameraIDs
expect(engine.getEvents).toHaveBeenCalledTimes(1);
expect(engine.getEvents).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
cameraIDs: new Set(['cam1', 'cam2']),
reviewed: false,
}),
undefined,
);
});
it('does not merge queries with different properties', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine, [
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam1' }) },
{ config: createCameraConfig({ ...baseCameraConfig, id: 'cam2' }) },
]);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const query1: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam1']),
reviewed: false,
};
const query2: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['cam2']),
reviewed: true,
};
engine.getEvents.mockResolvedValue(new Map());
engine.generateMediaFromEvents.mockReturnValue([]);
await manager.executeMediaQueries([query1, query2]);
// Should be called twice for different queries
expect(engine.getEvents).toHaveBeenCalledTimes(2);
});
it('handles single query without merging', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
engine.getEvents.mockResolvedValue(
new Map([[baseEventQuery, baseEventQueryResults]]),
);
engine.generateMediaFromEvents.mockReturnValue([]);
await manager.executeMediaQueries([baseEventQuery]);
expect(engine.getEvents).toHaveBeenCalledTimes(1);
expect(engine.getEvents).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
baseEventQuery,
undefined,
);
});
});
}); });
describe('should extend media queries', () => { describe('should extend media queries', () => {
@@ -1,11 +1,41 @@
import { assert, describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { SetReviewAction } from '../../../../src/card-controller/actions/actions/set-review'; import { SetReviewAction } from '../../../../src/card-controller/actions/actions/set-review';
import { ViewMedia, ViewMediaType } from '../../../../src/view/item'; import { ViewMediaType } from '../../../../src/view/item';
import { QueryResults } from '../../../../src/view/query-results'; import { QueryResults } from '../../../../src/view/query-results';
import { createCardAPI, createView, TestViewMedia } from '../../../test-utils'; import { createCardAPI, createView, TestViewMedia } from '../../../test-utils';
describe('SetReviewAction', () => { describe('SetReviewAction', () => {
it('should mark item as reviewed', async () => { it('should toggle item reviewed state', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: 'review' as ViewMediaType,
reviewed: false,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, true);
// toggleReviewed mutates the item in-place
expect(item.isReviewed()).toBe(true);
// Verify UI update is triggered to refresh menu icon
expect(api.getCardElementManager().update).toBeCalled();
});
it('should set reviewed to true when requested and currently false', async () => {
const api = createCardAPI(); const api = createCardAPI();
const item = new TestViewMedia({ const item = new TestViewMedia({
cameraID: 'camera.office', cameraID: 'camera.office',
@@ -28,22 +58,10 @@ describe('SetReviewAction', () => {
await action.execute(api); await action.execute(api);
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, true); expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, true);
expect(item.isReviewed()).toBe(true);
// Original item is NOT mutated; a clone is created and replaced.
expect(item.isReviewed()).toBe(false);
const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock
.calls[0][0];
const newResults = setViewParams?.params?.queryResults;
expect(newResults).toBeInstanceOf(QueryResults);
const newItem = newResults?.getSelectedResult();
expect(newItem).not.toBe(item);
assert(newItem instanceof ViewMedia);
expect(newItem?.isReviewed()).toBe(true);
}); });
it('should toggle review status when reviewed is not specified', async () => { it('should not act when requested state matches current state', async () => {
const api = createCardAPI(); const api = createCardAPI();
const item = new TestViewMedia({ const item = new TestViewMedia({
cameraID: 'camera.office', cameraID: 'camera.office',
@@ -60,25 +78,12 @@ describe('SetReviewAction', () => {
{ {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'set_review', advanced_camera_card_action: 'set_review',
reviewed: true,
}, },
); );
await action.execute(api); await action.execute(api);
// Toggle: isReviewed was true, so reviewed should be set to false. expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, false);
// Original item is NOT mutated; a clone is created and replaced.
expect(item.isReviewed()).toBe(true);
const setViewParams = vi.mocked(api.getViewManager().setViewByParameters).mock
.calls[0][0];
const newResults = setViewParams?.params?.queryResults;
expect(newResults).toBeInstanceOf(QueryResults);
const newItem = newResults?.getSelectedResult();
expect(newItem).not.toBe(item);
assert(newItem instanceof ViewMedia);
expect(newItem?.isReviewed()).toBe(false);
}); });
it('should not act on non-review media', async () => { it('should not act on non-review media', async () => {
@@ -139,4 +144,32 @@ describe('SetReviewAction', () => {
expect(api.getViewItemManager().reviewMedia).not.toBeCalled(); expect(api.getViewItemManager().reviewMedia).not.toBeCalled();
}); });
it('should not update UI if review action fails', async () => {
const api = createCardAPI();
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: 'review' as ViewMediaType,
reviewed: false,
});
const queryResults = new QueryResults({ results: [item], selectedIndex: 0 });
const view = createView({ queryResults });
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
vi.mocked(api.getViewItemManager().reviewMedia).mockRejectedValue(
new Error('error'),
);
const action = new SetReviewAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
},
);
await action.execute(api);
expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, true);
expect(api.getCardElementManager().update).not.toBeCalled();
});
}); });
@@ -2,11 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EffectsController } from '../../../src/components-lib/effects/effects-controller'; import { EffectsController } from '../../../src/components-lib/effects/effects-controller';
import { EffectComponent } from '../../../src/components-lib/effects/types'; import { EffectComponent } from '../../../src/components-lib/effects/types';
import { EffectName } from '../../../src/types'; import { EffectName } from '../../../src/types';
import { flushPromises } from '../../test-utils';
vi.mock('../../../src/components/effects/fireworks', () => ({ vi.mock('../../../src/components/effects/fireworks', () => ({
AdvancedCameraCardEffectFireworks: vi.fn(() => createMockEffectComponent()), AdvancedCameraCardEffectFireworks: vi.fn(() => createMockEffectComponent()),
})); }));
vi.mock('../../../src/components/effects/check', () => ({
AdvancedCameraCardEffectCheck: vi.fn(() => createMockEffectComponent()),
}));
vi.mock('../../../src/components/effects/ghost', () => ({ vi.mock('../../../src/components/effects/ghost', () => ({
AdvancedCameraCardEffectGhost: vi.fn(() => createMockEffectComponent()), AdvancedCameraCardEffectGhost: vi.fn(() => createMockEffectComponent()),
})); }));
@@ -39,10 +44,12 @@ describe('EffectsController', () => {
container = document.createElement('div'); container = document.createElement('div');
controller = new EffectsController(); controller = new EffectsController();
controller.setContainer(container); controller.setContainer(container);
vi.useFakeTimers();
}); });
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useRealTimers();
}); });
describe('startEffect', () => { describe('startEffect', () => {
@@ -86,6 +93,40 @@ describe('EffectsController', () => {
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
it('should load and start check effect', async () => {
expect(container.children.length).toBe(0);
await controller.startEffect('check');
expect(container.children.length).toBe(1);
});
it('should stop effect automatically after duration', async () => {
const effectPromise = controller.startEffect('snow', { duration: 1 });
// Wait for the effect to be added to the DOM.
await flushPromises();
expect(container.children.length).toBe(1);
vi.advanceTimersByTime(1000);
await effectPromise;
expect(container.children.length).toBe(0);
});
it('should clean up timer when stopped manually', async () => {
// Don't await startEffect as it waits for the duration
controller.startEffect('snow', { duration: 10 });
// Wait for the effect to be added to the DOM.
await flushPromises();
await controller.stopEffect('snow');
expect(container.children.length).toBe(0);
});
it('should set fadeIn to true by default', async () => { it('should set fadeIn to true by default', async () => {
await controller.startEffect('snow'); await controller.startEffect('snow');
+23 -34
View File
@@ -19,16 +19,15 @@ describe('MediaActions', () => {
describe('toggleReviewed', () => { describe('toggleReviewed', () => {
it('should return false if item is not review', async () => { it('should return false if item is not review', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = { viewItemManager: mock<ViewItemManager>() }; const viewItemManager = mock<ViewItemManager>();
expect(await toggleReviewed(item, options)).toBe(false); expect(await toggleReviewed(item, viewItemManager)).toBe(false);
}); });
it('should return false if manager is missing', async () => { it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Review }); const item = new TestViewMedia({ mediaType: ViewMediaType.Review });
const options = {};
expect(await toggleReviewed(item, options)).toBe(false); expect(await toggleReviewed(item)).toBe(false);
}); });
it('should toggle review status and update view', async () => { it('should toggle review status and update view', async () => {
@@ -48,9 +47,9 @@ describe('MediaActions', () => {
queryResults.clone.mockReturnValue(queryResults); queryResults.clone.mockReturnValue(queryResults);
queryResults.removeItem.mockReturnValue(queryResults); queryResults.removeItem.mockReturnValue(queryResults);
const options = { viewItemManager, viewManagerEpoch }; expect(await toggleReviewed(item, viewItemManager, viewManagerEpoch, false)).toBe(
true,
expect(await toggleReviewed(item, options)).toBe(true); );
expect(viewItemManager.reviewMedia).toHaveBeenCalledWith(item, true); expect(viewItemManager.reviewMedia).toHaveBeenCalledWith(item, true);
expect(item.isReviewed()).toBe(true); expect(item.isReviewed()).toBe(true);
expect(viewManager.setViewByParameters).toHaveBeenCalled(); expect(viewManager.setViewByParameters).toHaveBeenCalled();
@@ -70,9 +69,9 @@ describe('MediaActions', () => {
viewManager.getView.mockReturnValue(view); viewManager.getView.mockReturnValue(view);
view.queryResults = null; view.queryResults = null;
const options = { viewItemManager, viewManagerEpoch }; expect(await toggleReviewed(item, viewItemManager, viewManagerEpoch, false)).toBe(
true,
expect(await toggleReviewed(item, options)).toBe(true); );
expect(viewManager.setViewByParameters).not.toHaveBeenCalled(); expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
}); });
@@ -86,9 +85,8 @@ describe('MediaActions', () => {
viewItemManager.reviewMedia.mockRejectedValue(error); viewItemManager.reviewMedia.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await toggleReviewed(item, options)).toBe(false); expect(await toggleReviewed(item, viewItemManager)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message); expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore(); consoleSpy.mockRestore();
@@ -97,16 +95,17 @@ describe('MediaActions', () => {
describe('toggleFavorite', () => { describe('toggleFavorite', () => {
it('should return false if item is not media', async () => { it('should return false if item is not media', async () => {
const options = { viewItemManager: mock<ViewItemManager>() }; const viewItemManager = mock<ViewItemManager>();
expect(await toggleFavorite(null as unknown as ViewItem, options)).toBe(false); expect(await toggleFavorite(null as unknown as ViewItem, viewItemManager)).toBe(
false,
);
}); });
it('should return false if manager is missing', async () => { it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(await toggleFavorite(item, options)).toBe(false); expect(await toggleFavorite(item)).toBe(false);
}); });
it('should toggle favorite status', async () => { it('should toggle favorite status', async () => {
@@ -115,9 +114,8 @@ describe('MediaActions', () => {
favorite: false, favorite: false,
}); });
const viewItemManager = mock<ViewItemManager>(); const viewItemManager = mock<ViewItemManager>();
const options = { viewItemManager };
expect(await toggleFavorite(item, options)).toBe(true); expect(await toggleFavorite(item, viewItemManager)).toBe(true);
expect(viewItemManager.favorite).toHaveBeenCalledWith(item, true); expect(viewItemManager.favorite).toHaveBeenCalledWith(item, true);
}); });
@@ -131,9 +129,8 @@ describe('MediaActions', () => {
viewItemManager.favorite.mockRejectedValue(error); viewItemManager.favorite.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await toggleFavorite(item, options)).toBe(false); expect(await toggleFavorite(item, viewItemManager)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message); expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore(); consoleSpy.mockRestore();
@@ -143,17 +140,15 @@ describe('MediaActions', () => {
describe('downloadMedia', () => { describe('downloadMedia', () => {
it('should return false if manager is missing', async () => { it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(await downloadMedia(item, options)).toBe(false); expect(await downloadMedia(item)).toBe(false);
}); });
it('should download media', async () => { it('should download media', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewItemManager = mock<ViewItemManager>(); const viewItemManager = mock<ViewItemManager>();
const options = { viewItemManager };
expect(await downloadMedia(item, options)).toBe(true); expect(await downloadMedia(item, viewItemManager)).toBe(true);
expect(viewItemManager.download).toHaveBeenCalledWith(item); expect(viewItemManager.download).toHaveBeenCalledWith(item);
}); });
@@ -164,9 +159,8 @@ describe('MediaActions', () => {
viewItemManager.download.mockRejectedValue(error); viewItemManager.download.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await downloadMedia(item, options)).toBe(false); expect(await downloadMedia(item, viewItemManager)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message); expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore(); consoleSpy.mockRestore();
@@ -176,9 +170,8 @@ describe('MediaActions', () => {
describe('navigateToTimeline', () => { describe('navigateToTimeline', () => {
it('should return early if epoch is missing', () => { it('should return early if epoch is missing', () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(navigateToTimeline(item, options)).toBeUndefined(); expect(navigateToTimeline(item)).toBeUndefined();
}); });
it('should navigate to timeline with correct parameters', () => { it('should navigate to timeline with correct parameters', () => {
@@ -199,9 +192,7 @@ describe('MediaActions', () => {
return queryResults; return queryResults;
}); });
const options = { viewManagerEpoch }; navigateToTimeline(item, viewManagerEpoch);
navigateToTimeline(item, options);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({ expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: { params: {
@@ -220,9 +211,7 @@ describe('MediaActions', () => {
viewManagerEpoch.manager = viewManager; viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(null); viewManager.getView.mockReturnValue(null);
const options = { viewManagerEpoch }; navigateToTimeline(item, viewManagerEpoch);
navigateToTimeline(item, options);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({ expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: { params: {
+7
View File
@@ -45,6 +45,12 @@ describe('ViewMedia', () => {
expect(media.isReviewed()).toBeNull(); expect(media.isReviewed()).toBeNull();
expect(media.getIcon()).toBeNull(); expect(media.getIcon()).toBeNull();
expect(media.getSeverity()).toBeNull(); expect(media.getSeverity()).toBeNull();
expect(media.getFolder()).toBeNull();
});
it('should construct without options', () => {
const media = new ViewMedia(ViewMediaType.Clip);
expect(media.getCameraID()).toBeNull();
}); });
it('should clone', () => { it('should clone', () => {
@@ -111,5 +117,6 @@ describe('ViewFolder', () => {
expect(clone).not.toBe(item); expect(clone).not.toBe(item);
expect(clone).toBeInstanceOf(ViewFolder); expect(clone).toBeInstanceOf(ViewFolder);
expect(clone.getFolder()).toEqual(folder); expect(clone.getFolder()).toEqual(folder);
expect(clone.getPath()).toEqual([]);
}); });
}); });
+83 -10
View File
@@ -36,8 +36,26 @@ const createMocks = () => {
const cameraManager = mock<CameraManager>(); const cameraManager = mock<CameraManager>();
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
const store = mock<CameraManagerStore>(); const store = mock<CameraManagerStore>();
cameraManager.getStore.mockReturnValue(store); cameraManager.getStore.mockReturnValue(store);
cameraManager.getDefaultQueryParameters.mockReturnValue({}); cameraManager.getDefaultQueryParameters.mockReturnValue({});
store.getCameraIDs.mockReturnValue(new Set());
store.getCameraIDsWithCapability.mockReturnValue(new Set());
store.getAllDependentCameras.mockReturnValue(new Set());
store.getCameraConfig.mockReturnValue(createCameraConfig());
// By default, make getCameraCapabilities return a set of capabilities that
// matches everything, unless the test mocks it otherwise.
cameraManager.getCameraCapabilities.mockReturnValue(
createCapabilities({
clips: true,
snapshots: true,
recordings: true,
reviews: true,
}),
);
return { cameraManager, foldersManager, store }; return { cameraManager, foldersManager, store };
}; };
@@ -470,7 +488,6 @@ describe('UnifiedQueryBuilder', () => {
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const folder = createFolder({ id: 'folder1', title: 'Test' }); const folder = createFolder({ id: 'folder1', title: 'Test' });
const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }];
const query = builder.buildFolderQueryWithPath(folder, path, { const query = builder.buildFolderQueryWithPath(folder, path, {
limit: 10, limit: 10,
}); });
@@ -972,7 +989,7 @@ describe('UnifiedQueryBuilder', () => {
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office']));
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips()); const query = builder.buildCameraMediaQuery('events', { eventsSubtype: 'clips' });
assert(query); assert(query);
expect(query.getNodes()[0]).toMatchObject({ expect(query.getNodes()[0]).toMatchObject({
@@ -986,7 +1003,9 @@ describe('UnifiedQueryBuilder', () => {
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office']));
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery(MediaTypeSpec.snapshots()); const query = builder.buildCameraMediaQuery('events', {
eventsSubtype: 'snapshots',
});
assert(query); assert(query);
expect(query.getNodes()[0]).toMatchObject({ expect(query.getNodes()[0]).toMatchObject({
@@ -1000,7 +1019,7 @@ describe('UnifiedQueryBuilder', () => {
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office']));
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery(MediaTypeSpec.recordings()); const query = builder.buildCameraMediaQuery('recordings');
assert(query); assert(query);
expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Recording }); expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Recording });
@@ -1011,7 +1030,7 @@ describe('UnifiedQueryBuilder', () => {
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office']));
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery(MediaTypeSpec.reviews()); const query = builder.buildCameraMediaQuery('reviews');
assert(query); assert(query);
expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Review }); expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Review });
@@ -1022,8 +1041,9 @@ describe('UnifiedQueryBuilder', () => {
store.getAllDependentCameras.mockReturnValue(new Set(['camera.office', 'dep1'])); store.getAllDependentCameras.mockReturnValue(new Set(['camera.office', 'dep1']));
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips(), { const query = builder.buildCameraMediaQuery('events', {
cameraID: 'camera.office', cameraID: 'camera.office',
eventsSubtype: 'clips',
}); });
assert(query); assert(query);
@@ -1038,7 +1058,7 @@ describe('UnifiedQueryBuilder', () => {
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office']));
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), { const query = builder.buildCameraMediaQuery('recordings', {
limit: 30, limit: 30,
}); });
@@ -1054,7 +1074,7 @@ describe('UnifiedQueryBuilder', () => {
store.getCameraIDsWithCapability.mockReturnValue(new Set()); store.getCameraIDsWithCapability.mockReturnValue(new Set());
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips()); const query = builder.buildCameraMediaQuery('events', { eventsSubtype: 'clips' });
expect(query).toBeNull(); expect(query).toBeNull();
}); });
@@ -1063,7 +1083,7 @@ describe('UnifiedQueryBuilder', () => {
const { cameraManager, foldersManager } = createMocks(); const { cameraManager, foldersManager } = createMocks();
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery({ mediaType: 'folder' }); const query = builder.buildCameraMediaQuery('folder');
expect(query).toBeNull(); expect(query).toBeNull();
}); });
@@ -1073,7 +1093,7 @@ describe('UnifiedQueryBuilder', () => {
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office']));
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery({ mediaType: 'events' }); const query = builder.buildCameraMediaQuery('events');
assert(query); assert(query);
expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Event }); expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Event });
@@ -1084,6 +1104,59 @@ describe('UnifiedQueryBuilder', () => {
anyCapabilities: ['clips', 'snapshots'], anyCapabilities: ['clips', 'snapshots'],
}); });
}); });
it('should skip cameras without capabilities', () => {
const { cameraManager, foldersManager, store } = createMocks();
store.getCameraIDsWithCapability.mockReturnValue(
new Set(['capable', 'not-capable']),
);
cameraManager.getCameraCapabilities.mockImplementation((id) => {
if (id === 'capable') {
return createCapabilities({ clips: true });
}
return null;
});
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildCameraMediaQuery('events', { eventsSubtype: 'clips' });
assert(query);
expect(query.getNodes()).toHaveLength(1);
expect(query.getNodes()[0]).toMatchObject({
type: QueryType.Event,
cameraIDs: new Set(['capable']),
});
});
it('should build folder query when camera media type is folder', () => {
const { cameraManager, foldersManager, store } = createMocks();
store.getCameraIDs.mockReturnValue(new Set(['cam1']));
store.getCameraConfig.mockReturnValue(
createCameraConfig({
media: { type: 'folder', folders: ['good', 'bad'] },
}),
);
foldersManager.getFolder.mockImplementation((id) => {
if (id === 'good') {
return createFolder({ id });
}
return null;
});
foldersManager.getDefaultQueryParameters.mockImplementation((folder) => {
return folder ? createFolderQueryParams(folder, [{ ha: { id: 'id' } }]) : null;
});
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildDefaultCameraQuery();
assert(query);
// Expected result: good included, bad skipped.
expect(query.getNodes()).toHaveLength(1);
expect(query.getFolderQueries('good')).toHaveLength(1);
});
}); });
describe('default parameters merging', () => { describe('default parameters merging', () => {
+9
View File
@@ -96,6 +96,15 @@ describe('UnifiedQuery', () => {
expect(query.getMediaQueries({ cameraID: 'garage' })).toHaveLength(0); expect(query.getMediaQueries({ cameraID: 'garage' })).toHaveLength(0);
}); });
it('should filter media queries by type', () => {
const query = new UnifiedQuery();
query.addNode(createEventQuery('front'));
query.addNode(createRecordingQuery('front'));
expect(query.getMediaQueries({ type: QueryType.Event })).toHaveLength(1);
expect(query.getMediaQueries({ type: QueryType.Recording })).toHaveLength(1);
});
it('should check hasMediaQueriesOfType', () => { it('should check hasMediaQueriesOfType', () => {
const query = new UnifiedQuery(); const query = new UnifiedQuery();
query.addNode(createEventQuery('front')); query.addNode(createEventQuery('front'));
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewFolder } from '../../../src/view/item';
import { UnifiedQuery } from '../../../src/view/unified-query';
import {
getReviewedQueryFilterFromConfig,
getReviewedQueryFilterFromQuery,
} from '../../../src/view/utils/query-filter';
import { createEventQuery, createFolder, TestViewMedia } from '../../test-utils';
describe('query-filter', () => {
describe('getReviewedQueryFilterFromQuery', () => {
it('should return undefined if query is missing', () => {
expect(getReviewedQueryFilterFromQuery(null)).toBeUndefined();
});
it('should return undefined if item is missing', () => {
expect(getReviewedQueryFilterFromQuery(mock<UnifiedQuery>())).toBeUndefined();
});
it('should return undefined if item is not media', () => {
const query = mock<UnifiedQuery>();
const item = new ViewFolder(createFolder(), []);
expect(getReviewedQueryFilterFromQuery(query, item)).toBeUndefined();
});
it('should return undefined if item has no cameraID', () => {
const query = mock<UnifiedQuery>();
const item = new TestViewMedia({ cameraID: null });
expect(getReviewedQueryFilterFromQuery(query, item)).toBeUndefined();
});
it('should return undefined if zero or multiple media queries match', () => {
const query = mock<UnifiedQuery>();
const item = new TestViewMedia({ cameraID: 'camera-1' });
query.getMediaQueries.mockReturnValue([]);
expect(getReviewedQueryFilterFromQuery(query, item)).toBeUndefined();
query.getMediaQueries.mockReturnValue([
createEventQuery('camera-1'),
createEventQuery('camera-2'),
]);
expect(getReviewedQueryFilterFromQuery(query, item)).toBeUndefined();
});
it('should return boolean if exactly one media query matches', () => {
const query = mock<UnifiedQuery>();
const item = new TestViewMedia({ cameraID: 'camera-1' });
query.getMediaQueries.mockReturnValue([
createEventQuery('camera-1', { reviewed: true }),
]);
expect(getReviewedQueryFilterFromQuery(query, item)).toBe(true);
query.getMediaQueries.mockReturnValue([
createEventQuery('camera-1', { reviewed: false }),
]);
expect(getReviewedQueryFilterFromQuery(query, item)).toBe(false);
query.getMediaQueries.mockReturnValue([createEventQuery('camera-1')]);
expect(getReviewedQueryFilterFromQuery(query, item)).toBeUndefined();
});
});
describe('getReviewedQueryFilterFromConfig', () => {
it('should return true for reviewed', () => {
expect(getReviewedQueryFilterFromConfig('reviewed')).toBe(true);
});
it('should return undefined for all', () => {
expect(getReviewedQueryFilterFromConfig('all')).toBeUndefined();
expect(getReviewedQueryFilterFromConfig(undefined)).toBe(false);
});
it('should return false for unreviewed', () => {
expect(getReviewedQueryFilterFromConfig('unreviewed')).toBe(false);
});
});
});