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
+11 -4
View File
@@ -15,10 +15,8 @@ export class FoldersExecutor {
this._ha = engines?.ha ?? new HAFoldersEngine();
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
return (
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
);
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
return this._getFolderEngine(folder.type)?.getDefaultQueryParameters(folder) ?? null;
}
public generateChildFolderQuery(
@@ -79,6 +77,15 @@ export class FoldersExecutor {
);
}
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
return (
this._getFolderEngine(query.folder.type)?.areResultsFresh(
resultsTimestamp,
query,
) ?? true
);
}
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
switch (type) {
case folderTypeSchema.enum.ha:
+30 -10
View File
@@ -1,16 +1,17 @@
import { sub } from 'date-fns';
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../../conditions/types';
import {
FolderConfig,
folderTypeSchema,
HA_MEDIA_SOURCE_ROOT,
HAFolderPathComponent,
HAFolderConfig,
HAFolderPathComponent,
} from '../../../config/schema/folders';
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
import {
BrowseMedia,
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMediaCache,
BrowseMediaMetadata,
RichBrowseMedia,
@@ -22,16 +23,18 @@ import {
} from '../../../ha/browse-media/walker';
import { getMediaDownloadPath } from '../../../ha/download';
import { HomeAssistant } from '../../../ha/types';
import { QuerySource } from '../../../query-source.js';
import { Endpoint } from '../../../types';
import { ViewFolder, ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { ViewItemCapabilities } from '../../../view/types';
import {
DownloadHelpers,
EngineOptions,
FolderPathComponent,
FolderQuery,
FoldersEngine,
FolderPathComponent,
} from '../types';
import { MediaMatcher } from './media-matcher';
import { MetadataGenerator } from './metadata-generator.js';
@@ -83,17 +86,18 @@ export class HAFoldersEngine implements FoldersEngine {
return;
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
public getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null {
if (folder.type !== folderTypeSchema.enum.ha) {
return null;
}
return {
source: QuerySource.Folder,
folder,
path: this.getDefaultFolderPathComponents(folder.ha),
path: this._getDefaultPathComponents(folder.ha),
};
}
private getDefaultFolderPathComponents(
private _getDefaultPathComponents(
haFolderConfig?: HAFolderConfig,
): NonEmptyTuple<FolderPathComponent> {
const shouldAddDefaultRoot =
@@ -158,14 +162,21 @@ export class HAFoldersEngine implements FoldersEngine {
targets: BrowseMediaTarget<BrowseMediaMetadata>[],
): BrowseMediaStep<BrowseMediaMetadata>[] => {
const nextComponent = pathComponents.shift();
const limit = query.limit ?? null;
return [
{
targets,
metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) =>
metadataGenerator: (media, parent) =>
this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers),
// At the final step (no nextComponent), apply limit via earlyExit.
...(limit && {
earlyExit: (media) => media.length >= limit,
}),
...(nextComponent && {
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
matcher: (media) =>
this._mediaMatcher.match(hass, media, {
matchers: nextComponent.ha?.matchers,
// Set foldersOnly to true if there are more stages in the path,
@@ -187,9 +198,11 @@ export class HAFoldersEngine implements FoldersEngine {
},
);
return getViewItemsFromBrowseMediaArray(browseMedia, {
const results = getViewItemsFromBrowseMediaArray(browseMedia, {
folder: query.folder,
path: query.path,
});
return query.limit ? results.slice(0, query.limit) : results;
}
public generateChildFolderQuery(
@@ -202,7 +215,7 @@ export class HAFoldersEngine implements FoldersEngine {
}
// Get the full configured path to find parsers/matchers for this depth.
const fullPath = this.getDefaultFolderPathComponents(query.folder.ha);
const fullPath = this._getDefaultPathComponents(query.folder.ha);
const nextConfiguredComponent = fullPath[query.path.length];
// Use the configured component's parsers/matchers if available, otherwise
@@ -214,4 +227,11 @@ export class HAFoldersEngine implements FoldersEngine {
path: [...query.path, { folder, ha }],
};
}
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
return (
!!query &&
resultsTimestamp >= sub(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS })
);
}
}
+13 -3
View File
@@ -2,7 +2,9 @@ import { cloneDeep } from 'lodash-es';
import { ConditionState } from '../../conditions/types';
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { hasUnsupportedFilters } from '../../query-source.js';
import { Endpoint } from '../../types';
import { getFolderID } from '../../utils/folder';
import { ViewFolder, ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { CardFoldersAPI } from '../types';
@@ -26,7 +28,7 @@ export class FoldersManager {
public addFolders(folders: FolderConfigWithoutID[]): void {
for (const folder of folders) {
const folderNumber = this._folders.size;
const id = folder.id ?? `folder/${folderNumber.toString()}`;
const id = getFolderID(folder, folderNumber);
if (this._folders.has(id)) {
throw new FolderInitializationError(
localize('error.duplicate_folder_id'),
@@ -57,9 +59,9 @@ export class FoldersManager {
: this._folders.values().next().value ?? null;
}
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
public getDefaultQueryParameters(folder?: FolderConfig): FolderQuery | null {
const _folder = folder ?? this.getFolder();
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
return _folder ? this._executor.getDefaultQueryParameters(_folder) : null;
}
public generateChildFolderQuery(
@@ -74,12 +76,20 @@ export class FoldersManager {
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
if (hasUnsupportedFilters(query)) {
return null;
}
const hass = this._api.getHASSManager().getHASS();
return hass
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
: null;
}
public areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean {
return this._executor.areResultsFresh(resultsTimestamp, query);
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return this._executor.getItemCapabilities(item);
}
+9 -2
View File
@@ -3,6 +3,7 @@ import { ConditionState } from '../../conditions/types';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { BaseQuery, QueryFilters, QuerySource } from '../../query-source';
import { Endpoint } from '../../types';
import { AdvancedCameraCardError } from '../../types.js';
import { ViewFolder, ViewItem } from '../../view/item';
@@ -30,12 +31,14 @@ export interface FolderPathComponent extends FolderPathComponentMetadata {
folder?: ViewFolder;
}
export interface FolderQuery {
export interface FolderQuery extends BaseQuery, QueryFilters {
source: QuerySource.Folder;
folder: FolderConfig;
// A trail of paths to navigate back to the "root", with the last path being
// the path that this query directly refers to.
path: NonEmptyTuple<FolderPathComponent>;
limit?: number;
}
// ===============
@@ -47,7 +50,8 @@ export interface DownloadHelpers {
}
export interface FoldersEngine {
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
getDefaultQueryParameters(folder: FolderConfig): FolderQuery | null;
generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null;
expandFolder(
@@ -63,5 +67,8 @@ export interface FoldersEngine {
item: ViewItem,
options?: DownloadHelpers,
): Promise<Endpoint | null>;
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
areResultsFresh(resultsTimestamp: Date, query: FolderQuery): boolean;
}