fix: Improve review media filtering (#2322)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ export type EffectModule = { default: new () => EffectComponent };
|
||||
|
||||
export interface EffectOptions {
|
||||
fadeIn?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`
|
||||
: ''}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user