feat: Add support for Frigate reviews / detections [initial PR] (#2315)
- Add support for Frigate reviews / detections. - Add support for GenAI metadata. - Significant internal refactor to more flexible "UnifiedQuery" to allow mixing cameras with simple metadata and review metadata (e.g. a timeline view of a Frigate camera with reviews, and a Reolink camera with simple metadata). - Add support for folder media as camera media. There are a few more PRs to commit prior to this going live, but commiting this for now due to the scale of the change. BREAKING CHANGE: `media_type` and `events_type` are retired under `live`, `viewer` and `timeline` configuration sections, instead media type is associated (once) with the camera under `media`.
This commit is contained in:
+10
-1
@@ -24,6 +24,7 @@ import {
|
||||
PTZActionConfig,
|
||||
PTZActionPhase,
|
||||
} from '../config/schema/actions/custom/ptz.js';
|
||||
import { SetReviewActionConfig } from '../config/schema/actions/custom/set-review.js';
|
||||
import { SubstreamSelectActionConfig } from '../config/schema/actions/custom/substream-select.js';
|
||||
import { ViewActionConfig } from '../config/schema/actions/custom/view.js';
|
||||
import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js';
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
} from '../config/schema/actions/types.js';
|
||||
import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js';
|
||||
import { ServiceCallRequest } from '../ha/types.js';
|
||||
import { EffectName } from '../types.js';
|
||||
import type { EffectName } from '../types.js';
|
||||
import { arrayify } from './basic.js';
|
||||
|
||||
export function createGeneralAction(
|
||||
@@ -260,6 +261,14 @@ export function createEffectAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'set_review',
|
||||
reviewed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
|
||||
@@ -8,8 +8,6 @@ import { StyleInfo } from 'lit/directives/style-map';
|
||||
import { isEqualWith, mergeWith, round, uniq } from 'lodash-es';
|
||||
import { AdvancedCameraCardError } from '../types';
|
||||
|
||||
export type ModifyInterface<T, R> = Omit<T, keyof R> & R;
|
||||
|
||||
/**
|
||||
* Prettify a title by converting '_' to spaces and capitalizing words.
|
||||
* @param input The input string.
|
||||
|
||||
@@ -95,6 +95,9 @@ export class CarouselController {
|
||||
}
|
||||
|
||||
public selectSlide(index: number): void {
|
||||
if (index < 0 || index >= this._carousel.slideNodes().length) {
|
||||
return;
|
||||
}
|
||||
this._carousel.scrollTo(index, this._transitionEffect === 'none');
|
||||
|
||||
// This event exists to allow the caller to know the difference between
|
||||
@@ -102,6 +105,10 @@ export class CarouselController {
|
||||
// (e.g. carousel drags). See the note in auto-media-loaded-info.ts on how
|
||||
// this is used.
|
||||
const newSlide = this.getSlide(index);
|
||||
|
||||
/* istanbul ignore if: defensive guard for getSlide returning null which can
|
||||
only happen with an index out of bounds, which is guarded against above --
|
||||
@preserve */
|
||||
if (newSlide) {
|
||||
fireAdvancedCameraCardEvent<CarouselSelected>(
|
||||
this._parent,
|
||||
@@ -115,11 +122,10 @@ export class CarouselController {
|
||||
}
|
||||
|
||||
protected _refreshCarouselContents = (): void => {
|
||||
const newSlides = getChildrenFromElement(this._parent);
|
||||
const slidesChanged = !isEqual(this._carousel.slideNodes(), newSlides);
|
||||
const slides = getChildrenFromElement(this._parent);
|
||||
const slidesChanged = !isEqual(this._carousel.slideNodes(), slides);
|
||||
if (slidesChanged) {
|
||||
this._carousel.destroy();
|
||||
this._carousel = this._createCarousel(newSlides);
|
||||
this._carousel.reInit({ slides });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FolderConfig } from '../config/schema/folders';
|
||||
import { RawAdvancedCameraCardConfig } from '../config/types';
|
||||
|
||||
/**
|
||||
* Get a folder id.
|
||||
* @param config The folder config (either parsed or raw).
|
||||
* @param index The index of the folder in the config array (used for default ID).
|
||||
* @returns A folder id.
|
||||
*/
|
||||
export function getFolderID(
|
||||
config?: FolderConfig | RawAdvancedCameraCardConfig | null,
|
||||
index?: number,
|
||||
): string {
|
||||
return (
|
||||
(typeof config?.id === 'string' && config.id) || `folder/${(index ?? 0).toString()}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager';
|
||||
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||
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,
|
||||
): Promise<boolean> {
|
||||
if (!ViewItemClassifier.isReview(item) || !options.viewItemManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newState = !item.isReviewed();
|
||||
try {
|
||||
await options.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),
|
||||
},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function toggleFavorite(
|
||||
item: ViewItem,
|
||||
options: MediaActionOptions,
|
||||
): Promise<boolean> {
|
||||
if (!ViewItemClassifier.isMedia(item) || !options.viewItemManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newState = !item.isFavorite();
|
||||
try {
|
||||
await options.viewItemManager.favorite(item, newState);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function downloadMedia(
|
||||
item: ViewItem,
|
||||
options: MediaActionOptions,
|
||||
): Promise<boolean> {
|
||||
if (!options.viewItemManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await options.viewItemManager.download(item);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function navigateToTimeline(item: ViewItem, options: MediaActionOptions): void {
|
||||
if (!options.viewManagerEpoch) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.viewManagerEpoch.manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'timeline',
|
||||
queryResults: options.viewManagerEpoch.manager
|
||||
.getView()
|
||||
?.queryResults?.clone()
|
||||
.selectResultIfFound((media) => media === item),
|
||||
},
|
||||
modifiers: [new RemoveContextViewModifier(['timeline'])],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OverlayMessage } from '../types.js';
|
||||
import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js';
|
||||
|
||||
export function dispatchShowOverlayMessageEvent(
|
||||
element: HTMLElement,
|
||||
message: OverlayMessage,
|
||||
): void {
|
||||
fireAdvancedCameraCardEvent(element, 'overlay-message:show', message);
|
||||
}
|
||||
|
||||
export function dispatchDismissOverlayMessageEvent(element: HTMLElement): void {
|
||||
fireAdvancedCameraCardEvent(element, 'overlay-message:dismiss');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ViewItem } from '../view/item';
|
||||
|
||||
export interface CardMediaReviewEventTarget extends EventTarget {
|
||||
addEventListener(
|
||||
event: 'advanced-camera-card:media:reviewed',
|
||||
listener: (this: CardMediaReviewEventTarget, ev: CustomEvent<ViewItem>) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
removeEventListener(
|
||||
event: 'advanced-camera-card:media:reviewed',
|
||||
listener: (this: CardMediaReviewEventTarget, ev: CustomEvent<ViewItem>) => void,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
Reference in New Issue
Block a user