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
+6 -5
View File
@@ -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
+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_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
+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_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:
+13 -7
View File
@@ -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
+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_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
```
+35 -2
View File
@@ -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<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>(
queries: T[],
results: ViewItem[],
@@ -691,7 +724,7 @@ export class CameraManager {
query: QT | QT[],
engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> {
const _queries = arrayify(query);
const _queries = this._mergeCompatibleQueries(arrayify(query));
const results = new Map<QT, QueryReturnType<QT>>();
const queryStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
@@ -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<SetReviewActionCon
public async execute(api: CardActionsAPI): Promise<void> {
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<SetReviewActionCon
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.
// Test-case: Setting a media item reviewed via the menu, should update the
// reviewed state in a thumbnail.
const clonedItem = item.clone();
clonedItem.setReviewed(targetReviewedState);
api.getViewManager().setViewByParameters({
params: {
queryResults: queryResults.clone().replaceItem(item, clonedItem),
},
});
// Trigger UI update to refresh menu icon state
if (results[0]) {
api.getCardElementManager().update();
}
}
}
@@ -3,7 +3,7 @@ import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
import { QueryResults } from '../../view/query-results';
import { UnifiedQuery } from '../../view/unified-query';
import { MediaTypeSpec, UnifiedQueryBuilder } from '../../view/unified-query-builder';
import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
import { View } from '../../view/view';
import { CardViewAPI } from '../types';
@@ -126,8 +126,9 @@ export class ViewQueryExecutor {
case 'clips':
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.clips(), {
builder.buildCameraMediaQuery('events', {
cameraID: cameraForQuery,
eventsSubtype: 'clips',
limit: this._getLimit(),
}),
)),
@@ -139,8 +140,9 @@ export class ViewQueryExecutor {
case 'snapshots':
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.snapshots(), {
builder.buildCameraMediaQuery('events', {
cameraID: cameraForQuery,
eventsSubtype: 'snapshots',
limit: this._getLimit(),
}),
)),
@@ -151,7 +153,7 @@ export class ViewQueryExecutor {
case 'recordings':
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.recordings(), {
builder.buildCameraMediaQuery('recordings', {
cameraID: cameraForQuery,
limit: this._getLimit(),
}),
@@ -163,7 +165,7 @@ export class ViewQueryExecutor {
case 'reviews':
viewModifiers.push(
...(await executeQuery(
builder.buildCameraMediaQuery(MediaTypeSpec.reviews(), {
builder.buildCameraMediaQuery('reviews', {
cameraID: cameraForQuery,
limit: this._getLimit(),
}),
@@ -1,7 +1,12 @@
import { EffectName, EffectsControllerAPI } from '../../types';
import { Timer } from '../../utils/timer';
import { EffectComponent, EffectModule, EffectOptions } from './types';
const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = {
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<EffectName, EffectModule> = new Map();
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
private _durationTimers: Map<EffectName, Timer> = 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<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> {
const timer = this._durationTimers.get(effect);
if (timer) {
timer.stop();
this._durationTimers.delete(effect);
}
if (!this._activeInstances.has(effect)) {
return;
}
+1
View File
@@ -7,4 +7,5 @@ export type EffectModule = { default: new () => EffectComponent };
export interface EffectOptions {
fadeIn?: boolean;
duration?: number;
}
+13 -4
View File
@@ -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;
+4 -1
View File
@@ -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 {
+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 { 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) {
+4
View File
@@ -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),
),
);
}
+13 -4
View File
@@ -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,
);
}
}}
></advanced-camera-card-icon>`
@@ -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);
}
}}
></advanced-camera-card-icon>`
@@ -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);
}
}}
></advanced-camera-card-icon>`
+4
View File
@@ -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}
>
</advanced-camera-card-thumbnail-feature>
${this.details
@@ -3,6 +3,7 @@ import { EffectName } from '../../../../types';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
const effectNameSchema = z.enum([
'check',
'fireworks',
'ghost',
'hearts',
+7
View File
@@ -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
+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_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;
+41 -15
View File
@@ -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')}
</div>
`
: ''}
+8
View File
@@ -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
+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",
"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": {
+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 EffectName = 'fireworks' | 'ghost' | 'hearts' | 'shamrocks' | 'snow';
export type EffectName =
| 'check'
| 'fireworks'
| 'ghost'
| 'hearts'
| 'shamrocks'
| 'snow';
export interface EffectsControllerAPI {
startEffect(name: EffectName, options?: EffectOptions): Promise<void>;
+33 -26
View File
@@ -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<boolean> {
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<boolean> {
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<boolean> {
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),
+102 -90
View File
@@ -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<string>;
what?: Set<string>;
where?: Set<string>;
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<string>,
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<string>,
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<string>,
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<string> | null,
mediaTypes: Set<ViewMediaType> | null,
options?: FilterQueryBuildOptions,
options?: MediaQueryBuildOptions,
): UnifiedQuery | null {
const query = new UnifiedQuery();
@@ -249,43 +242,21 @@ export class UnifiedQueryBuilder {
private _buildFilterQueryNode(
mediaType: ViewMediaType,
cameraIDs: Set<string>,
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<FolderPathComponent>,
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<string>,
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<string>,
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<string>,
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([]);
});
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', () => {
@@ -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();
});
});
@@ -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');
+23 -34
View File
@@ -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<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 () => {
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<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 () => {
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<ViewItemManager>();
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<ViewItemManager>();
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: {
+7
View File
@@ -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([]);
});
});
+83 -10
View File
@@ -36,8 +36,26 @@ const createMocks = () => {
const cameraManager = mock<CameraManager>();
const foldersManager = mock<FoldersManager>();
const store = mock<CameraManagerStore>();
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', () => {
+9
View File
@@ -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'));
+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);
});
});
});