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:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
+10 -1
View File
@@ -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`
-2
View File
@@ -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.
+10 -4
View File
@@ -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 });
}
};
+17
View File
@@ -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()}`
);
}
+92
View File
@@ -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'])],
});
}
+13
View File
@@ -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');
}
+24
View File
@@ -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;
}