fix: Improve review media filtering (#2322)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user