From f4e905a7fbf9480d5d687bc32b49b50705483a81 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 24 Jan 2026 16:22:12 -0800 Subject: [PATCH] fix: Improve review media filtering (#2322) --- docs/configuration/cameras/README.md | 11 +- docs/configuration/live.md | 2 + docs/configuration/media-gallery.md | 2 + docs/configuration/menu.md | 20 +- docs/configuration/timeline.md | 2 + src/camera-manager/manager.ts | 37 +++- .../actions/actions/set-review.ts | 37 ++-- .../view/view-query-executor.ts | 12 +- .../effects/effects-controller.ts | 25 +++ src/components-lib/effects/types.ts | 1 + .../media/details-controller.ts | 17 +- src/components-lib/menu-button-controller.ts | 5 +- src/components/effects/check.ts | 24 +++ src/components/gallery/gallery.ts | 7 +- src/components/thumbnail-carousel.ts | 4 + src/components/thumbnail/feature/feature.ts | 17 +- src/components/thumbnail/thumbnail.ts | 4 + src/config/schema/actions/custom/effect.ts | 1 + src/config/schema/cameras.ts | 7 + src/const.ts | 2 + src/editor.ts | 56 +++-- src/images/README.md | 8 + src/images/check-circle.svg | 3 + src/localize/languages/en.json | 6 + src/scss/check.scss | 32 +++ src/types.ts | 8 +- src/utils/media-actions.ts | 59 +++--- src/view/unified-query-builder.ts | 192 ++++++++++-------- src/view/utils/query-filter.ts | 46 +++++ tests/camera-manager/manager.test.ts | 104 ++++++++++ .../actions/actions/set-review.test.ts | 97 ++++++--- .../effects/effects-controller.test.ts | 41 ++++ tests/utils/media-actions.test.ts | 57 +++--- tests/view/item.test.ts | 7 + tests/view/unified-query-builder.test.ts | 93 ++++++++- tests/view/unified-query.test.ts | 9 + tests/view/utils/query-filter.test.ts | 82 ++++++++ 37 files changed, 884 insertions(+), 253 deletions(-) create mode 100644 src/components/effects/check.ts create mode 100644 src/images/check-circle.svg create mode 100644 src/scss/check.scss create mode 100644 src/view/utils/query-filter.ts create mode 100644 tests/view/utils/query-filter.test.ts diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index cb66c450..8affc6b7 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -178,11 +178,12 @@ cameras: # [...] ``` -| 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. | -| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). | -| `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). | +| 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. | +| `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). | ### Media Types diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 7071d549..a7b5d70d 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -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_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_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_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`. | @@ -221,6 +222,7 @@ live: show_details: false show_download_control: false show_favorite_control: true + show_info_control: true show_review_control: true show_timeline_control: false mode: none diff --git a/docs/configuration/media-gallery.md b/docs/configuration/media-gallery.md index 4f6fc885..85f06fb4 100644 --- a/docs/configuration/media-gallery.md +++ b/docs/configuration/media-gallery.md @@ -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_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_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_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`. | @@ -65,6 +66,7 @@ media_gallery: show_details: false show_download_control: false show_favorite_control: true + show_info_control: true show_review_control: true show_timeline_control: false actions: diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index ffa74b4c..e18579f5 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -46,18 +46,19 @@ menu: | `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. | | `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. | | `timeline` | The `timeline` menu button: show the event timeline. | ### Options for each button -| 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`. | -| `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. | -| `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). | -| `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`. | +| 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`. | +| `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). | +| `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`. | ### Additional options: `microphone` @@ -179,6 +180,11 @@ menu: enabled: true alignment: matching icon: mdi:home + set_review: + priority: 50 + enabled: false + alignment: matching + icon: mdi:check-circle snapshots: priority: 50 enabled: true diff --git a/docs/configuration/timeline.md b/docs/configuration/timeline.md index c587b4fd..7db4f434 100644 --- a/docs/configuration/timeline.md +++ b/docs/configuration/timeline.md @@ -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_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_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_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`. | @@ -91,6 +92,7 @@ timeline: show_details: true show_download_control: true show_favorite_control: true + show_info_control: true show_review_control: true show_timeline_control: true ``` diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index cb2a1f56..5eb1ed09 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -1,6 +1,7 @@ import { add } from 'date-fns'; -import { cloneDeep, sum } from 'lodash-es'; +import { cloneDeep, omit, sum } from 'lodash-es'; import PQueue from 'p-queue'; +import { EqualityMap } from '../cache/equality-map.js'; import { CardCameraAPI } from '../card-controller/types.js'; import { sortItems } from '../card-controller/view/sort.js'; 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(queries: T[]): T[] { + if (queries.length <= 1) { + return queries; + } + + type CameraLessQuery = Omit; + + // Compare queries ignoring the camera parameter. + const groups = new EqualityMap(); + + 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( queries: T[], results: ViewItem[], @@ -691,7 +724,7 @@ export class CameraManager { query: QT | QT[], engineOptions?: EngineOptions, ): Promise>> { - const _queries = arrayify(query); + const _queries = this._mergeCompatibleQueries(arrayify(query)); const results = new Map>(); const queryStartTime = new Date(); const hass = this._api.getHASSManager().getHASS(); diff --git a/src/card-controller/actions/actions/set-review.ts b/src/card-controller/actions/actions/set-review.ts index a377215a..db152b21 100644 --- a/src/card-controller/actions/actions/set-review.ts +++ b/src/card-controller/actions/actions/set-review.ts @@ -1,5 +1,7 @@ import { SetReviewActionConfig } from '../../../config/schema/actions/custom/set-review'; +import { toggleReviewed } from '../../../utils/media-actions'; import { ViewItemClassifier } from '../../../view/item-classifier'; +import { getReviewedQueryFilterFromQuery } from '../../../view/utils/query-filter'; import { CardActionsAPI } from '../../types'; import { AdvancedCameraCardAction } from './base'; @@ -7,7 +9,8 @@ export class SetReviewAction extends AdvancedCameraCardAction { await super.execute(api); - const view = api.getViewManager().getView(); + const viewManager = api.getViewManager(); + const view = viewManager.getView(); const queryResults = view?.queryResults; const item = queryResults?.getSelectedResult(); @@ -15,20 +18,26 @@ export class SetReviewAction extends AdvancedCameraCardAction Promise> = { + check: async () => { + const module = await import('../../components/effects/check'); + return { default: module.AdvancedCameraCardEffectCheck }; + }, fireworks: async () => { const module = await import('../../components/effects/fireworks'); return { default: module.AdvancedCameraCardEffectFireworks }; @@ -29,6 +34,7 @@ type EffectsContainer = HTMLElement | DocumentFragment; export class EffectsController implements EffectsControllerAPI { private _importedModules: Map = new Map(); private _activeInstances: Map = new Map(); + private _durationTimers: Map = new Map(); private _container: EffectsContainer | null = null; public setContainer(container: EffectsContainer | null): void { @@ -55,9 +61,28 @@ export class EffectsController implements EffectsControllerAPI { effectComponent.fadeIn = options?.fadeIn ?? true; this._container.appendChild(effectComponent); this._activeInstances.set(name, effectComponent); + + const duration = options?.duration; + if (duration !== undefined) { + return new Promise((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 { + const timer = this._durationTimers.get(effect); + if (timer) { + timer.stop(); + this._durationTimers.delete(effect); + } + if (!this._activeInstances.has(effect)) { return; } diff --git a/src/components-lib/effects/types.ts b/src/components-lib/effects/types.ts index 29468bc9..ca5a491f 100644 --- a/src/components-lib/effects/types.ts +++ b/src/components-lib/effects/types.ts @@ -7,4 +7,5 @@ export type EffectModule = { default: new () => EffectComponent }; export interface EffectOptions { fadeIn?: boolean; + duration?: number; } diff --git a/src/components-lib/media/details-controller.ts b/src/components-lib/media/details-controller.ts index 0955ecd6..cf04a30c 100644 --- a/src/components-lib/media/details-controller.ts +++ b/src/components-lib/media/details-controller.ts @@ -22,6 +22,10 @@ export interface OverlayControlsContext { viewItemManager?: ViewItemManager; viewManagerEpoch?: ViewManagerEpoch; capabilities?: ViewItemCapabilities | null; + + // Whether to filter reviewed/unreviewed items after changing the reviewed + // state. + filterReviewed?: boolean; } export class MediaDetailsController { @@ -231,7 +235,12 @@ export class MediaDetailsController { : localize('common.set_reviews.reviewed'), icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' }, 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; }, }); @@ -244,7 +253,7 @@ export class MediaDetailsController { icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' }, emphasis: isFavorite ? 'medium' : undefined, callback: async () => { - const success = await toggleFavorite(item, context); + const success = await toggleFavorite(item, context.viewItemManager); return success ? this.getMessage(context) : null; }, }); @@ -255,7 +264,7 @@ export class MediaDetailsController { title: localize('thumbnail.download'), icon: { icon: 'mdi:download' }, callback: async () => { - await downloadMedia(item, context); + await downloadMedia(item, context.viewItemManager); // Close overlay message after download. return null; @@ -268,7 +277,7 @@ export class MediaDetailsController { title: localize('thumbnail.timeline'), icon: { icon: 'mdi:target' }, callback: () => { - navigateToTimeline(item, context); + navigateToTimeline(item, context.viewManagerEpoch); // Close overlay after timeline navigation return null; diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index f1f57c6f..c6ef7f4a 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -432,7 +432,10 @@ export class MenuButtonController { view?: View | null, ): MenuItem | null { const selectedItem = view?.queryResults?.getSelectedResult(); - if (!ViewItemClassifier.isMedia(selectedItem)) { + if ( + !ViewItemClassifier.isMedia(selectedItem) || + !(view?.isViewerView() || view?.isGalleryView() || view?.is('timeline')) + ) { return null; } return { diff --git a/src/components/effects/check.ts b/src/components/effects/check.ts new file mode 100644 index 00000000..484de816 --- /dev/null +++ b/src/components/effects/check.ts @@ -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`${unsafeHTML(checkCircleSVG)}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(checkStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-effect-check': AdvancedCameraCardEffectCheck; + } +} diff --git a/src/components/gallery/gallery.ts b/src/components/gallery/gallery.ts index 82740b2e..a314500d 100644 --- a/src/components/gallery/gallery.ts +++ b/src/components/gallery/gallery.ts @@ -34,6 +34,7 @@ import { ViewItemClassifier } from '../../view/item-classifier.js'; import { ViewFolder, ViewItem } from '../../view/item.js'; import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js'; import { UnifiedQueryRunner } from '../../view/unified-query-runner.js'; +import { getReviewedQueryFilterFromQuery } from '../../view/utils/query-filter.js'; import '../media-filter.js'; import '../message.js'; import { renderMessage } from '../message.js'; @@ -133,9 +134,8 @@ export class AdvancedCameraCardGallery extends LitElement { } protected _renderThumbnails(): TemplateResult | void { - const selected = this.viewManagerEpoch?.manager - .getView() - ?.queryResults?.getSelectedResult(); + const view = this.viewManagerEpoch?.manager.getView(); + const selected = view?.queryResults?.getSelectedResult(); return html` ${this._controller.getItems()?.map( @@ -161,6 +161,7 @@ export class AdvancedCameraCardGallery extends LitElement { .show_review_control} ?show_info_control=${!!this.galleryConfig?.controls.thumbnails .show_info_control} + .filterReviewed=${getReviewedQueryFilterFromQuery(view?.query, item)} @click=${(ev: Event) => { stopEventFromActivatingCardWideActions(ev); if (ViewItemClassifier.isMedia(item) && this.viewManagerEpoch) { diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 327ccf13..e2435abc 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -31,6 +31,7 @@ import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card- import { ViewItemClassifier } from '../view/item-classifier.js'; import { ViewItem, ViewMedia } from '../view/item.js'; import { UnifiedQueryBuilder } from '../view/unified-query-builder.js'; +import { getReviewedQueryFilterFromQuery } from '../view/utils/query-filter.js'; import './carousel.js'; import './thumbnail/thumbnail.js'; @@ -151,6 +152,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { selected: boolean, clickCallback: (item: ViewItem, ev: Event) => void, seekTarget?: Date, + filterReviewed?: boolean, ): TemplateResult { const classes = { embla__slide: true, @@ -161,6 +163,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { class="${classMap(classes)}" .cameraManager=${this.cameraManager} .hass=${this.hass} + .filterReviewed=${filterReviewed} .item=${item} .viewManagerEpoch=${this.viewManagerEpoch} .viewItemManager=${this.viewItemManager} @@ -210,6 +213,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { selectedIndex === thumbnails.length, clickHandler, view?.context?.mediaViewer?.seek, + getReviewedQueryFilterFromQuery(view?.query, item), ), ); } diff --git a/src/components/thumbnail/feature/feature.ts b/src/components/thumbnail/feature/feature.ts index c1032a29..f7014eff 100644 --- a/src/components/thumbnail/feature/feature.ts +++ b/src/components/thumbnail/feature/feature.ts @@ -67,6 +67,9 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { @property({ attribute: true, type: Boolean }) public show_info_control = false; + @property({ attribute: false }) + public filterReviewed?: boolean; + private _controller = new ThumbnailFeatureController(); protected willUpdate(changedProperties: PropertyValues): void { @@ -83,6 +86,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { viewItemManager: this.viewItemManager, viewManagerEpoch: this.viewManagerEpoch, 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) => { stopEventFromActivatingCardWideActions(ev); if (this.item) { - await toggleReviewed(this.item, this._getControlContext()); + await toggleReviewed( + this.item, + this.viewItemManager, + this.viewManagerEpoch, + this.filterReviewed, + ); } }} >` @@ -179,7 +188,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { stopEventFromActivatingCardWideActions(ev); if ( this.item && - (await toggleFavorite(this.item, this._getControlContext())) + (await toggleFavorite(this.item, this.viewItemManager)) ) { this.requestUpdate(); } @@ -210,7 +219,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { @click=${(ev: Event) => { stopEventFromActivatingCardWideActions(ev); if (this.item) { - navigateToTimeline(this.item, this._getControlContext()); + navigateToTimeline(this.item, this.viewManagerEpoch); } }} >` @@ -223,7 +232,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { @click=${async (ev: Event) => { stopEventFromActivatingCardWideActions(ev); if (this.item) { - await downloadMedia(this.item, this._getControlContext()); + await downloadMedia(this.item, this.viewItemManager); } }} >` diff --git a/src/components/thumbnail/thumbnail.ts b/src/components/thumbnail/thumbnail.ts index d454e4e8..da642e42 100644 --- a/src/components/thumbnail/thumbnail.ts +++ b/src/components/thumbnail/thumbnail.ts @@ -55,6 +55,9 @@ export class AdvancedCameraCardThumbnail extends LitElement { @property({ attribute: true, type: Boolean }) public show_info_control = false; + @property({ attribute: false }) + public filterReviewed?: boolean; + @property({ attribute: false }) public seek?: Date; @@ -80,6 +83,7 @@ export class AdvancedCameraCardThumbnail extends LitElement { .show_download_control=${this.show_download_control} .show_review_control=${this.show_review_control} .show_info_control=${this.show_info_control} + .filterReviewed=${this.filterReviewed} > ${this.details diff --git a/src/config/schema/actions/custom/effect.ts b/src/config/schema/actions/custom/effect.ts index 1e1a9a3a..9ae4c774 100644 --- a/src/config/schema/actions/custom/effect.ts +++ b/src/config/schema/actions/custom/effect.ts @@ -3,6 +3,7 @@ import { EffectName } from '../../../../types'; import { advancedCameraCardCustomActionsBaseSchema } from './base'; const effectNameSchema = z.enum([ + 'check', 'fireworks', 'ghost', 'hearts', diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 0943cca8..b500b5a0 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -191,12 +191,19 @@ export type CameraMediaType = (typeof CAMERA_MEDIA_TYPES)[number]; const cameraMediaConfigDefault = { 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({ type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type), events_type: eventsMediaTypeSchema.optional(), folders: z.array(z.string()).optional(), + reviewed: z + .enum(CAMERA_MEDIA_REVIEWED_FILTERS) + .default(cameraMediaConfigDefault.reviewed), }); export const cameraConfigSchema = z diff --git a/src/const.ts b/src/const.ts index 98785563..214417bd 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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_EVENTS_TYPE = `${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 = `${CONF_CAMERAS}.#.media.folders` as const; diff --git a/src/editor.ts b/src/editor.ts index 96fc38ea..0d6ca4a5 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -73,6 +73,7 @@ import { CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE, CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, + CONF_CAMERAS_ARRAY_MEDIA_REVIEWED, CONF_CAMERAS_ARRAY_MEDIA_TYPE, CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_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[] = [ { value: '', label: '' }, { 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'), }, )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_REVIEWED, cameraIndex), + this._cameraMediaReviewedOptions, + { + label: localize('config.cameras.media.reviewed'), + }, + )} ${this._renderOptionSelector( getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, cameraIndex), folderOptions, @@ -2895,20 +2919,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard min: BUTTON_SIZE_MIN, })} ${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('fullscreen')} + ${this._renderMenuButton('cameras') /* */} + ${this._renderMenuButton('clips')} + ${this._renderMenuButton('display_mode')} + ${this._renderMenuButton('download') /* */} ${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( 'microphone', @@ -2918,12 +2939,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { label: localize('config.menu.buttons.type') }, )}`, )} - ${this._renderMenuButton('play') /* */} ${this._renderMenuButton('mute') /* */} - ${this._renderMenuButton('screenshot')} - ${this._renderMenuButton('display_mode')} + ${this._renderMenuButton('play')} ${this._renderMenuButton('ptz_controls')} ${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')} ` : ''} diff --git a/src/images/README.md b/src/images/README.md index a5817903..98289132 100644 --- a/src/images/README.md +++ b/src/images/README.md @@ -29,3 +29,11 @@ $ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-o - Outline addded 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 diff --git a/src/images/check-circle.svg b/src/images/check-circle.svg new file mode 100644 index 00000000..bb575085 --- /dev/null +++ b/src/images/check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 6880f24b..a4a40bc1 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -145,6 +145,12 @@ "editor_label": "Default Media", "events_type": "Events media subtype", "folders": "Folder IDs", + "reviewed": "Review status filter", + "revieweds": { + "all": "All (both reviewed and unreviewed)", + "reviewed": "Only reviewed", + "unreviewed": "Only unreviewed" + }, "type": "Default media type" }, "motioneye": { diff --git a/src/scss/check.scss b/src/scss/check.scss new file mode 100644 index 00000000..0c2ca721 --- /dev/null +++ b/src/scss/check.scss @@ -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; + } +} diff --git a/src/types.ts b/src/types.ts index ba1fbfd9..3d4dc9b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -208,7 +208,13 @@ export const signedPathSchema = z.object({ }); export type SignedPath = z.infer; -export type EffectName = 'fireworks' | 'ghost' | 'hearts' | 'shamrocks' | 'snow'; +export type EffectName = + | 'check' + | 'fireworks' + | 'ghost' + | 'hearts' + | 'shamrocks' + | 'snow'; export interface EffectsControllerAPI { startEffect(name: EffectName, options?: EffectOptions): Promise; diff --git a/src/utils/media-actions.ts b/src/utils/media-actions.ts index 03eb11bc..f35f7254 100644 --- a/src/utils/media-actions.ts +++ b/src/utils/media-actions.ts @@ -5,51 +5,55 @@ import { ViewItem } from '../view/item'; import { ViewItemClassifier } from '../view/item-classifier'; import { errorToConsole } from './basic'; -interface MediaActionOptions { - viewItemManager?: ViewItemManager; - viewManagerEpoch?: ViewManagerEpoch; -} - export async function toggleReviewed( item: ViewItem, - options: MediaActionOptions, + viewItemManager?: ViewItemManager, + viewManagerEpoch?: ViewManagerEpoch, + filterReviewed?: boolean, ): Promise { - if (!ViewItemClassifier.isReview(item) || !options.viewItemManager) { + if (!ViewItemClassifier.isReview(item) || !viewItemManager) { return false; } const newState = !item.isReviewed(); try { - await options.viewItemManager.reviewMedia(item, newState); + await viewItemManager.reviewMedia(item, newState); } catch (e) { errorToConsole(e as Error); return false; } item.setReviewed(newState); - // Remove from view results - const view = options.viewManagerEpoch?.manager.getView(); - if (view?.queryResults) { - options.viewManagerEpoch?.manager.setViewByParameters({ - params: { - queryResults: view.queryResults.clone().removeItem(item), - }, - }); + // Only remove from query results if the new state conflicts with the filter: + // - 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) { + viewManagerEpoch?.manager.setViewByParameters({ + params: { + queryResults: view.queryResults.clone().removeItem(item), + }, + }); + } } return true; } export async function toggleFavorite( item: ViewItem, - options: MediaActionOptions, + viewItemManager?: ViewItemManager, ): Promise { - if (!ViewItemClassifier.isMedia(item) || !options.viewItemManager) { + if (!ViewItemClassifier.isMedia(item) || !viewItemManager) { return false; } const newState = !item.isFavorite(); try { - await options.viewItemManager.favorite(item, newState); + await viewItemManager.favorite(item, newState); } catch (e) { errorToConsole(e as Error); return false; @@ -59,14 +63,14 @@ export async function toggleFavorite( export async function downloadMedia( item: ViewItem, - options: MediaActionOptions, + viewItemManager?: ViewItemManager, ): Promise { - if (!options.viewItemManager) { + if (!viewItemManager) { return false; } try { - await options.viewItemManager.download(item); + await viewItemManager.download(item); } catch (e) { errorToConsole(e as Error); return false; @@ -74,15 +78,18 @@ export async function downloadMedia( return true; } -export function navigateToTimeline(item: ViewItem, options: MediaActionOptions): void { - if (!options.viewManagerEpoch) { +export function navigateToTimeline( + item: ViewItem, + viewManagerEpoch?: ViewManagerEpoch, +): void { + if (!viewManagerEpoch) { return; } - options.viewManagerEpoch.manager.setViewByParameters({ + viewManagerEpoch.manager.setViewByParameters({ params: { view: 'timeline', - queryResults: options.viewManagerEpoch.manager + queryResults: viewManagerEpoch.manager .getView() ?.queryResults?.clone() .selectResultIfFound((media) => media === item), diff --git a/src/view/unified-query-builder.ts b/src/view/unified-query-builder.ts index 1398467f..a01ace7f 100644 --- a/src/view/unified-query-builder.ts +++ b/src/view/unified-query-builder.ts @@ -11,31 +11,26 @@ import { FoldersManager } from '../card-controller/folders/manager'; import { FolderPathComponent, FolderQuery } from '../card-controller/folders/types'; import { CameraMediaType } from '../config/schema/cameras'; 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 { arrayify } from '../utils/basic'; import { QueryNode, UnifiedQuery } from '../view/unified-query'; +import { getReviewedQueryFilterFromConfig } from './utils/query-filter'; -interface MediaQueryBuildOptions { +interface MediaQueryBuildOptions extends QueryFilters { start?: Date; end?: Date; limit?: number; } -interface FilterQueryBuildOptions extends MediaQueryBuildOptions { - favorite?: boolean; - tags?: Set; - what?: Set; - where?: Set; - reviewed?: boolean; -} - -interface QueryLimitOptions { +interface QueryFiltersOptions extends QueryFilters { limit?: number; } +type MediaType = 'events' | 'recordings' | 'reviews' | 'folder'; + export interface MediaTypeSpec { - mediaType: 'events' | 'recordings' | 'reviews' | 'folder'; + mediaType: MediaType; eventsSubtype?: 'clips' | 'snapshots'; } @@ -149,7 +144,7 @@ export class UnifiedQueryBuilder { public buildReviewsQuery( cameraIDs: Set, - options?: MediaQueryBuildOptions & { reviewed?: boolean }, + options?: MediaQueryBuildOptions, ): UnifiedQuery | null { const query = this._addNode( new UnifiedQuery(), @@ -160,11 +155,9 @@ export class UnifiedQueryBuilder { private _buildReviewsQueryNode( cameraIDs: Set, - options?: MediaQueryBuildOptions & { reviewed?: boolean }, + options?: MediaQueryBuildOptions, ): ReviewQuery | null { - return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options, { - reviewed: options?.reviewed, - }); + return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options); } private _buildBaseQueryNode( @@ -182,7 +175,6 @@ export class UnifiedQueryBuilder { type: QueryType.Review, cameraIDs: Set, options?: MediaQueryBuildOptions, - extraProps?: { reviewed?: boolean }, ): ReviewQuery | null; private _buildBaseQueryNode( type: QueryType.Event | QueryType.Recording | QueryType.Review, @@ -200,6 +192,7 @@ export class UnifiedQueryBuilder { cameraIDs, ...this._mergeDefaultsForCameras(cameraIDs, type), ...this._extractCommonOptions(options), + ...this._extractFilterOptions(options), ...extraProps, }; } @@ -217,7 +210,7 @@ export class UnifiedQueryBuilder { public buildFilterQuery( cameraIDs: Set | null, mediaTypes: Set | null, - options?: FilterQueryBuildOptions, + options?: MediaQueryBuildOptions, ): UnifiedQuery | null { const query = new UnifiedQuery(); @@ -249,43 +242,21 @@ export class UnifiedQueryBuilder { private _buildFilterQueryNode( mediaType: ViewMediaType, cameraIDs: Set, - options?: FilterQueryBuildOptions, + options?: MediaQueryBuildOptions, ): 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) { - case 'clips': { - const node = this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, { + case 'clips': + return this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, { hasClip: true, }); - /* istanbul ignore next: see class note on code coverage -- @preserve */ - return node ? { ...node, ...filterProps } : null; - } - case 'snapshots': { - const node = this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, { + case 'snapshots': + return this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, { hasSnapshot: true, }); - /* istanbul ignore next: see class note on code coverage -- @preserve */ - return node ? { ...node, ...filterProps } : null; - } - case 'recordings': { - 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; - } + case 'recordings': + return this._buildBaseQueryNode(QueryType.Recording, cameraIDs, options); + case 'reviews': + return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options); } } @@ -296,7 +267,7 @@ export class UnifiedQueryBuilder { public buildFolderQueryWithPath( folder: FolderConfig, path: NonEmptyTuple, - options?: QueryLimitOptions, + options?: QueryFiltersOptions, ): UnifiedQuery { const query = new UnifiedQuery(); const folderQuery: FolderQuery = { @@ -311,7 +282,7 @@ export class UnifiedQueryBuilder { public buildDefaultFolderQuery( folderID?: string, - options?: QueryLimitOptions, + options?: QueryFiltersOptions, ): UnifiedQuery | null { const query = new UnifiedQuery(); this._addNode(query, this._buildFolderQueryNode(folderID, options)); @@ -320,7 +291,7 @@ export class UnifiedQueryBuilder { private _buildFolderQueryNodesForCameras( cameraIDs: Set, - options?: QueryLimitOptions, + options?: QueryFiltersOptions, ): QueryNode[] { const nodes: QueryNode[] = []; for (const cameraID of cameraIDs) { @@ -339,7 +310,7 @@ export class UnifiedQueryBuilder { private _buildFolderQueryNode( folderID?: string, - options?: QueryLimitOptions, + options?: QueryFiltersOptions, ): QueryNode | null { const folder = this._foldersManager.getFolder(folderID); const params = folder && this._foldersManager.getDefaultQueryParameters(folder); @@ -357,7 +328,7 @@ export class UnifiedQueryBuilder { public buildDefaultCameraQuery( cameraID?: string, - options?: QueryLimitOptions, + options?: QueryFiltersOptions, ): UnifiedQuery | null { const cameraIDs = cameraID ? this._cameraManager.getStore().getAllDependentCameras(cameraID) @@ -372,7 +343,7 @@ export class UnifiedQueryBuilder { private _buildDefaultCameraQueryNodes( cameraID: string, - options?: QueryLimitOptions, + options?: QueryFiltersOptions, ): QueryNode | QueryNode[] | null { const mediaConfig = this._cameraManager.getStore().getCameraConfig(cameraID)?.media; const spec = this._resolveMediaTypeSpec( @@ -384,7 +355,7 @@ export class UnifiedQueryBuilder { return null; } - return this._buildQueryNodesCapabilityUnchecked(spec, new Set([cameraID]), { + return this._buildQueryNodesCapabilityUnchecked(spec, cameraID, { limit: options?.limit, }); } @@ -394,33 +365,23 @@ export class UnifiedQueryBuilder { // ========================================================================= public buildCameraMediaQuery( - spec: MediaTypeSpec, - options?: QueryLimitOptions & { + mediaType: MediaType, + options?: QueryFiltersOptions & { cameraID?: string; + eventsSubtype?: 'clips' | 'snapshots'; }, ): UnifiedQuery | null { - let neededCapability: CapabilitySearchKeys; - 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. - return null; + if (mediaType === 'folder') { + // Folders are handled separately by buildDefaultFolderQuery. + 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 ? this._cameraManager .getStore() @@ -428,10 +389,33 @@ export class UnifiedQueryBuilder { : this._cameraManager.getStore().getCameraIDsWithCapability(neededCapability); const query = new UnifiedQuery(); - this._addNode( - query, - this._buildQueryNodesCapabilityUnchecked(spec, cameraIDs, options), - ); + 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( + query, + this._buildQueryNodesCapabilityUnchecked(cameraSpec, cameraID, resolvedOptions), + ); + } return query.hasNodes() ? query : null; } @@ -441,7 +425,9 @@ export class UnifiedQueryBuilder { eventsType?: 'clips' | 'snapshots' | 'all', ): MediaTypeSpec | null { const capabilities = this._cameraManager.getCameraCapabilities(cameraID); - if (!capabilities) { + const config = this._cameraManager.getStore().getCameraConfig(cameraID); + + if (!capabilities || !config) { return null; } @@ -473,25 +459,41 @@ export class UnifiedQueryBuilder { return hasReviews ? MediaTypeSpec.reviews() : null; case 'folder': return MediaTypeSpec.folder(); - case 'events': - if (eventsType === 'all' && hasClips && hasSnapshots) { + case 'events': { + const configEventsType = eventsType ?? config.media?.events_type; + if ( + (!configEventsType || configEventsType === 'all') && + hasClips && + hasSnapshots + ) { 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(); } - if ((eventsType === 'all' || eventsType === 'snapshots') && hasSnapshots) { + if (targetType === 'snapshots' && hasSnapshots) { return MediaTypeSpec.snapshots(); } return null; + } } } private _buildQueryNodesCapabilityUnchecked( spec: MediaTypeSpec, - cameraIDs: Set, - options?: QueryLimitOptions, + cameraID: string, + options?: QueryFiltersOptions, ): QueryNode | QueryNode[] | null { + const cameraIDs = new Set([cameraID]); switch (spec.mediaType) { case 'events': 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( cameraIDs: Set, queryType: QueryType, diff --git a/src/view/utils/query-filter.ts b/src/view/utils/query-filter.ts new file mode 100644 index 00000000..4bfb7012 --- /dev/null +++ b/src/view/utils/query-filter.ts @@ -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; +} diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index af74fec9..9acf808b 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -822,6 +822,110 @@ describe('CameraManager', () => { expect(await manager.executeMediaQueries([baseEventQuery])).toEqual([]); }); + + describe('should merge compatible queries', () => { + it('merges queries with identical properties', async () => { + const api = createCardAPI(); + const engine = mock(); + 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(); + 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(); + 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', () => { diff --git a/tests/card-controller/actions/actions/set-review.test.ts b/tests/card-controller/actions/actions/set-review.test.ts index c3a948cf..00dbd751 100644 --- a/tests/card-controller/actions/actions/set-review.test.ts +++ b/tests/card-controller/actions/actions/set-review.test.ts @@ -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 { ViewMedia, ViewMediaType } from '../../../../src/view/item'; +import { ViewMediaType } from '../../../../src/view/item'; import { QueryResults } from '../../../../src/view/query-results'; import { createCardAPI, createView, TestViewMedia } from '../../../test-utils'; 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 item = new TestViewMedia({ cameraID: 'camera.office', @@ -28,22 +58,10 @@ describe('SetReviewAction', () => { await action.execute(api); expect(api.getViewItemManager().reviewMedia).toBeCalledWith(item, 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); + expect(item.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 item = new TestViewMedia({ cameraID: 'camera.office', @@ -60,25 +78,12 @@ describe('SetReviewAction', () => { { action: 'fire-dom-event', advanced_camera_card_action: 'set_review', + reviewed: true, }, ); await action.execute(api); - // Toggle: isReviewed was true, so reviewed should be set to false. - 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); + expect(api.getViewItemManager().reviewMedia).not.toBeCalled(); }); it('should not act on non-review media', async () => { @@ -139,4 +144,32 @@ describe('SetReviewAction', () => { 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(); + }); }); diff --git a/tests/components-lib/effects/effects-controller.test.ts b/tests/components-lib/effects/effects-controller.test.ts index a8823f37..4562564b 100644 --- a/tests/components-lib/effects/effects-controller.test.ts +++ b/tests/components-lib/effects/effects-controller.test.ts @@ -2,11 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { EffectsController } from '../../../src/components-lib/effects/effects-controller'; import { EffectComponent } from '../../../src/components-lib/effects/types'; import { EffectName } from '../../../src/types'; +import { flushPromises } from '../../test-utils'; vi.mock('../../../src/components/effects/fireworks', () => ({ AdvancedCameraCardEffectFireworks: vi.fn(() => createMockEffectComponent()), })); +vi.mock('../../../src/components/effects/check', () => ({ + AdvancedCameraCardEffectCheck: vi.fn(() => createMockEffectComponent()), +})); + vi.mock('../../../src/components/effects/ghost', () => ({ AdvancedCameraCardEffectGhost: vi.fn(() => createMockEffectComponent()), })); @@ -39,10 +44,12 @@ describe('EffectsController', () => { container = document.createElement('div'); controller = new EffectsController(); controller.setContainer(container); + vi.useFakeTimers(); }); afterEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); }); describe('startEffect', () => { @@ -86,6 +93,40 @@ describe('EffectsController', () => { 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 () => { await controller.startEffect('snow'); diff --git a/tests/utils/media-actions.test.ts b/tests/utils/media-actions.test.ts index b45aed77..d354b54f 100644 --- a/tests/utils/media-actions.test.ts +++ b/tests/utils/media-actions.test.ts @@ -19,16 +19,15 @@ describe('MediaActions', () => { describe('toggleReviewed', () => { it('should return false if item is not review', async () => { const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); - const options = { viewItemManager: mock() }; + const viewItemManager = mock(); - expect(await toggleReviewed(item, options)).toBe(false); + expect(await toggleReviewed(item, viewItemManager)).toBe(false); }); it('should return false if manager is missing', async () => { 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 () => { @@ -48,9 +47,9 @@ describe('MediaActions', () => { queryResults.clone.mockReturnValue(queryResults); queryResults.removeItem.mockReturnValue(queryResults); - const options = { viewItemManager, viewManagerEpoch }; - - expect(await toggleReviewed(item, options)).toBe(true); + expect(await toggleReviewed(item, viewItemManager, viewManagerEpoch, false)).toBe( + true, + ); expect(viewItemManager.reviewMedia).toHaveBeenCalledWith(item, true); expect(item.isReviewed()).toBe(true); expect(viewManager.setViewByParameters).toHaveBeenCalled(); @@ -70,9 +69,9 @@ describe('MediaActions', () => { viewManager.getView.mockReturnValue(view); view.queryResults = null; - const options = { viewItemManager, viewManagerEpoch }; - - expect(await toggleReviewed(item, options)).toBe(true); + expect(await toggleReviewed(item, viewItemManager, viewManagerEpoch, false)).toBe( + true, + ); expect(viewManager.setViewByParameters).not.toHaveBeenCalled(); }); @@ -86,9 +85,8 @@ describe('MediaActions', () => { viewItemManager.reviewMedia.mockRejectedValue(error); 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); consoleSpy.mockRestore(); @@ -97,16 +95,17 @@ describe('MediaActions', () => { describe('toggleFavorite', () => { it('should return false if item is not media', async () => { - const options = { viewItemManager: mock() }; + const viewItemManager = mock(); - 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 () => { 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 () => { @@ -115,9 +114,8 @@ describe('MediaActions', () => { favorite: false, }); const viewItemManager = mock(); - const options = { viewItemManager }; - expect(await toggleFavorite(item, options)).toBe(true); + expect(await toggleFavorite(item, viewItemManager)).toBe(true); expect(viewItemManager.favorite).toHaveBeenCalledWith(item, true); }); @@ -131,9 +129,8 @@ describe('MediaActions', () => { viewItemManager.favorite.mockRejectedValue(error); 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); consoleSpy.mockRestore(); @@ -143,17 +140,15 @@ describe('MediaActions', () => { describe('downloadMedia', () => { it('should return false if manager is missing', async () => { 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 () => { const item = new TestViewMedia({ mediaType: ViewMediaType.Clip }); const viewItemManager = mock(); - const options = { viewItemManager }; - expect(await downloadMedia(item, options)).toBe(true); + expect(await downloadMedia(item, viewItemManager)).toBe(true); expect(viewItemManager.download).toHaveBeenCalledWith(item); }); @@ -164,9 +159,8 @@ describe('MediaActions', () => { viewItemManager.download.mockRejectedValue(error); 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); consoleSpy.mockRestore(); @@ -176,9 +170,8 @@ describe('MediaActions', () => { describe('navigateToTimeline', () => { it('should return early if epoch is missing', () => { 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', () => { @@ -199,9 +192,7 @@ describe('MediaActions', () => { return queryResults; }); - const options = { viewManagerEpoch }; - - navigateToTimeline(item, options); + navigateToTimeline(item, viewManagerEpoch); expect(viewManager.setViewByParameters).toHaveBeenCalledWith({ params: { @@ -220,9 +211,7 @@ describe('MediaActions', () => { viewManagerEpoch.manager = viewManager; viewManager.getView.mockReturnValue(null); - const options = { viewManagerEpoch }; - - navigateToTimeline(item, options); + navigateToTimeline(item, viewManagerEpoch); expect(viewManager.setViewByParameters).toHaveBeenCalledWith({ params: { diff --git a/tests/view/item.test.ts b/tests/view/item.test.ts index 0b0cbc6d..23a2a129 100644 --- a/tests/view/item.test.ts +++ b/tests/view/item.test.ts @@ -45,6 +45,12 @@ describe('ViewMedia', () => { expect(media.isReviewed()).toBeNull(); expect(media.getIcon()).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', () => { @@ -111,5 +117,6 @@ describe('ViewFolder', () => { expect(clone).not.toBe(item); expect(clone).toBeInstanceOf(ViewFolder); expect(clone.getFolder()).toEqual(folder); + expect(clone.getPath()).toEqual([]); }); }); diff --git a/tests/view/unified-query-builder.test.ts b/tests/view/unified-query-builder.test.ts index cba40dc4..6e70957e 100644 --- a/tests/view/unified-query-builder.test.ts +++ b/tests/view/unified-query-builder.test.ts @@ -36,8 +36,26 @@ const createMocks = () => { const cameraManager = mock(); const foldersManager = mock(); const store = mock(); + cameraManager.getStore.mockReturnValue(store); 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 }; }; @@ -470,7 +488,6 @@ describe('UnifiedQueryBuilder', () => { const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); const folder = createFolder({ id: 'folder1', title: 'Test' }); const path: [FolderPathComponent] = [{ ha: { id: 'Root' } }]; - const query = builder.buildFolderQueryWithPath(folder, path, { limit: 10, }); @@ -972,7 +989,7 @@ describe('UnifiedQueryBuilder', () => { store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips()); + const query = builder.buildCameraMediaQuery('events', { eventsSubtype: 'clips' }); assert(query); expect(query.getNodes()[0]).toMatchObject({ @@ -986,7 +1003,9 @@ describe('UnifiedQueryBuilder', () => { store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery(MediaTypeSpec.snapshots()); + const query = builder.buildCameraMediaQuery('events', { + eventsSubtype: 'snapshots', + }); assert(query); expect(query.getNodes()[0]).toMatchObject({ @@ -1000,7 +1019,7 @@ describe('UnifiedQueryBuilder', () => { store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery(MediaTypeSpec.recordings()); + const query = builder.buildCameraMediaQuery('recordings'); assert(query); expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Recording }); @@ -1011,7 +1030,7 @@ describe('UnifiedQueryBuilder', () => { store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery(MediaTypeSpec.reviews()); + const query = builder.buildCameraMediaQuery('reviews'); assert(query); expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Review }); @@ -1022,8 +1041,9 @@ describe('UnifiedQueryBuilder', () => { store.getAllDependentCameras.mockReturnValue(new Set(['camera.office', 'dep1'])); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips(), { + const query = builder.buildCameraMediaQuery('events', { cameraID: 'camera.office', + eventsSubtype: 'clips', }); assert(query); @@ -1038,7 +1058,7 @@ describe('UnifiedQueryBuilder', () => { store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), { + const query = builder.buildCameraMediaQuery('recordings', { limit: 30, }); @@ -1054,7 +1074,7 @@ describe('UnifiedQueryBuilder', () => { store.getCameraIDsWithCapability.mockReturnValue(new Set()); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery(MediaTypeSpec.clips()); + const query = builder.buildCameraMediaQuery('events', { eventsSubtype: 'clips' }); expect(query).toBeNull(); }); @@ -1063,7 +1083,7 @@ describe('UnifiedQueryBuilder', () => { const { cameraManager, foldersManager } = createMocks(); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery({ mediaType: 'folder' }); + const query = builder.buildCameraMediaQuery('folder'); expect(query).toBeNull(); }); @@ -1073,7 +1093,7 @@ describe('UnifiedQueryBuilder', () => { store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera.office'])); const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); - const query = builder.buildCameraMediaQuery({ mediaType: 'events' }); + const query = builder.buildCameraMediaQuery('events'); assert(query); expect(query.getNodes()[0]).toMatchObject({ type: QueryType.Event }); @@ -1084,6 +1104,59 @@ describe('UnifiedQueryBuilder', () => { 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', () => { diff --git a/tests/view/unified-query.test.ts b/tests/view/unified-query.test.ts index e8ebb5d2..8a129831 100644 --- a/tests/view/unified-query.test.ts +++ b/tests/view/unified-query.test.ts @@ -96,6 +96,15 @@ describe('UnifiedQuery', () => { 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', () => { const query = new UnifiedQuery(); query.addNode(createEventQuery('front')); diff --git a/tests/view/utils/query-filter.test.ts b/tests/view/utils/query-filter.test.ts new file mode 100644 index 00000000..2a5886a8 --- /dev/null +++ b/tests/view/utils/query-filter.test.ts @@ -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())).toBeUndefined(); + }); + + it('should return undefined if item is not media', () => { + const query = mock(); + const item = new ViewFolder(createFolder(), []); + + expect(getReviewedQueryFilterFromQuery(query, item)).toBeUndefined(); + }); + + it('should return undefined if item has no cameraID', () => { + const query = mock(); + 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(); + 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(); + 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); + }); + }); +});