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
+14 -1
View File
@@ -1,6 +1,7 @@
import {
EventViewMedia,
RecordingViewMedia,
ReviewViewMedia,
ViewFolder,
ViewItem,
ViewMedia,
@@ -19,6 +20,9 @@ export class ViewItemClassifier {
public static isRecording(item?: ViewItem | null): item is RecordingViewMedia {
return this.isMedia(item) && item.getMediaType() === 'recording';
}
public static isReview(item?: ViewItem | null): item is ReviewViewMedia {
return this.isMedia(item) && item.getMediaType() === 'review';
}
public static isClip(item?: ViewItem | null): boolean {
return this.isMedia(item) && item.getMediaType() === 'clip';
}
@@ -26,6 +30,15 @@ export class ViewItemClassifier {
return this.isMedia(item) && item.getMediaType() === 'snapshot';
}
public static isVideo(item?: ViewItem | null): boolean {
return this.isMedia(item) && (this.isClip(item) || this.isRecording(item));
return (
this.isMedia(item) &&
(this.isClip(item) ||
this.isRecording(item) ||
// Reviews always have a video.
this.isReview(item))
);
}
public static supportsTimeline(item?: ViewItem | null): item is ViewMedia {
return this.isMedia(item) && !!item.getStartTime();
}
}
+54 -1
View File
@@ -1,9 +1,13 @@
import { clone } from 'lodash-es';
import { FolderPathComponent } from '../card-controller/folders/types';
import { FolderConfig } from '../config/schema/folders';
import { Severity } from '../types';
export enum ViewMediaType {
Clip = 'clip',
Snapshot = 'snapshot',
Recording = 'recording',
Review = 'review',
}
export enum VideoContentType {
@@ -14,6 +18,7 @@ export enum VideoContentType {
export interface ViewMediaSourceOptions {
cameraID?: string;
folder?: FolderConfig;
path?: readonly FolderPathComponent[];
}
export class ViewMedia {
@@ -59,6 +64,9 @@ export class ViewMedia {
public getTitle(): string | null {
return null;
}
public getDescription(): string | null {
return null;
}
public getThumbnail(): string | null {
return null;
}
@@ -68,6 +76,9 @@ export class ViewMedia {
public isFavorite(): boolean | null {
return null;
}
public isReviewed(): boolean | null {
return null;
}
public includesTime(seek: Date): boolean {
const startTime = this.getStartTime();
const endTime = this.getUsableEndTime();
@@ -80,9 +91,25 @@ export class ViewMedia {
public setFavorite(_favorite: boolean): void {
return;
}
public getSeverity(): Severity | null {
return null;
}
public getWhere(): string[] | null {
return null;
}
/**
* Creates a shallow clone of this ViewMedia instance.
*
* This is needed because Lit components use reference equality (===) to
* detect property changes. When we mutate an item's state (e.g.,
* setReviewed), the reference doesn't change, so Lit doesn't re-render. By
* cloning the item before mutation and replacing it in the QueryResults, we
* ensure Lit sees a new reference and updates the UI.
*/
public clone(): this {
return clone(this);
}
}
export interface EventViewMedia extends ViewMedia {
@@ -96,6 +123,12 @@ export interface RecordingViewMedia extends ViewMedia {
getEventCount(): number | null;
}
export interface ReviewViewMedia extends ViewMedia {
getWhat(): string[] | null;
isReviewed(): boolean | null;
setReviewed(reviewed: boolean): void;
}
interface ViewFolderParameters {
icon?: string | null;
id?: string | null;
@@ -105,14 +138,20 @@ interface ViewFolderParameters {
export class ViewFolder {
private _folder: FolderConfig;
private _path: readonly FolderPathComponent[];
private _icon: string | null;
private _id: string | null;
private _title: string | null;
private _thumbnail: string | null;
constructor(folder: FolderConfig, params?: ViewFolderParameters) {
constructor(
folder: FolderConfig,
path: readonly FolderPathComponent[],
params?: ViewFolderParameters,
) {
this._folder = folder;
this._path = path;
this._icon = params?.icon ?? null;
this._id = params?.id ?? null;
@@ -123,21 +162,35 @@ export class ViewFolder {
public getFolder(): FolderConfig {
return this._folder;
}
public getPath(): readonly FolderPathComponent[] {
return this._path;
}
public getID(): string | null {
return this._id;
}
public getTitle(): string | null {
return this._title;
}
public getDescription(): string | null {
return null;
}
public getThumbnail(): string | null {
return this._thumbnail;
}
public getIcon(): string | null {
return this._icon;
}
public getSeverity(): Severity | null {
return null;
}
public isFavorite(): boolean | null {
return null;
}
/** See ViewMedia.clone() for explanation. */
public clone(): this {
return clone(this);
}
}
export type ViewItem = ViewMedia | ViewFolder;
-57
View File
@@ -1,57 +0,0 @@
import { EventMediaQuery, FolderViewQuery, Query, RecordingMediaQuery } from './query';
export type QueryType = 'event' | 'recording' | 'folder';
type MediaType = 'clips' | 'snapshots' | 'recordings';
export class QueryClassifier {
public static isEventQuery(query?: Query | null): query is EventMediaQuery {
return query instanceof EventMediaQuery;
}
public static isRecordingQuery(query?: Query | null): query is RecordingMediaQuery {
return query instanceof RecordingMediaQuery;
}
public static isMediaQuery(
query?: Query | null,
): query is EventMediaQuery | RecordingMediaQuery {
return this.isEventQuery(query) || this.isRecordingQuery(query);
}
public static isFolderQuery(query?: Query | null): query is FolderViewQuery {
return query instanceof FolderViewQuery;
}
public static isClipsQuery(query?: Query | null): boolean {
return (
this.isEventQuery(query) && !!query?.getQuery()?.every((query) => query.hasClip)
);
}
public static isSnapshotQuery(query?: Query | null): boolean {
return (
this.isEventQuery(query) &&
!!query?.getQuery()?.every((query) => query.hasSnapshot)
);
}
public static getQueryType(query?: Query | null): QueryType | null {
return this.isEventQuery(query)
? 'event'
: this.isRecordingQuery(query)
? 'recording'
: this.isFolderQuery(query)
? 'folder'
: null;
}
public static getMediaType(query?: Query | null): MediaType | null {
return this.isClipsQuery(query)
? 'clips'
: this.isSnapshotQuery(query)
? 'snapshots'
: this.isRecordingQuery(query)
? 'recordings'
: null;
}
}
+89
View File
@@ -86,6 +86,57 @@ class ResultSlice {
this.selectIndex(resultIndex);
}
}
/**
* Remove an item from this slice, preserving selection where possible.
* If the selected item is removed, selects the previous item.
* @param item The item to remove.
* @returns true if the item was found and removed, false otherwise.
*/
public removeItem(item: ViewItem): boolean {
const index = this._results.indexOf(item);
if (index === -1) {
return false;
}
// Copy-on-write: Create a new array reference only when modifying
this._results = [...this._results];
this._results.splice(index, 1);
// Adjust selection: if removed was selected, clamp to valid range; if after, decrement
if (this._selectedIndex !== null && this._selectedIndex >= index) {
if (this._selectedIndex === index) {
// Removed the selected item: clamp to new valid range or null if empty
this._selectedIndex =
this._results.length > 0 ? Math.min(index, this._results.length - 1) : null;
} else {
// Removed an item before the selected one: decrement
this._selectedIndex = this._selectedIndex - 1;
}
}
return true;
}
/**
* Replace an item in this slice with a new item (e.g., a clone with updated state).
* Selection is preserved if the replaced item was selected.
* @param oldItem The item to replace.
* @param newItem The new item to insert in its place.
* @returns true if the item was found and replaced, false otherwise.
*/
public replaceItem(oldItem: ViewItem, newItem: ViewItem): boolean {
const index = this._results.indexOf(oldItem);
if (index === -1) {
return false;
}
// When an item is replaced, we create a new array so that users can simply
// compare the results objects to determine equality (e.g. trigger Lit
// rendering for thumbnails that are marked as reviewed).
this._results = [...this._results];
this._results[index] = newItem;
return true;
}
}
interface ResultSliceSelectionCriteria {
@@ -142,6 +193,44 @@ export class QueryResults {
return copy;
}
/**
* Remove a specific item from the results.
* Note: This mutates the current instance. Use clone() first if needed.
* @param item The item to remove from results.
* @returns This QueryResults instance for chaining.
*/
public removeItem(item: ViewItem): QueryResults {
if (!this._main.removeItem(item)) {
return this;
}
// Also remove from the relevant camera slice
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
if (cameraID) {
this._cameras.get(cameraID)?.removeItem(item);
}
return this;
}
/**
* Replace an item with a new item (e.g., a clone with updated state).
* Note: This mutates the current instance. Use clone() first if needed.
* @param oldItem The item to replace.
* @param newItem The new item to insert in its place.
* @returns This QueryResults instance for chaining.
*/
public replaceItem(oldItem: ViewItem, newItem: ViewItem): QueryResults {
if (!this._main.replaceItem(oldItem, newItem)) {
return this;
}
const cameraID = ViewItemClassifier.isMedia(oldItem) ? oldItem.getCameraID() : null;
if (cameraID) {
this._cameras.get(cameraID)?.replaceItem(oldItem, newItem);
}
return this;
}
public isSupersetOf(that: QueryResults): boolean {
const thisItemIDs = new Set(this._main.getResults()?.map((item) => item.getID()));
const thatItemIDs = new Set(that._main.getResults()?.map((item) => item.getID()));
-120
View File
@@ -1,120 +0,0 @@
import { cloneDeep, isEqual, uniqWith } from 'lodash-es';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js';
import { FolderQuery } from '../card-controller/folders/types.js';
import { setify } from '../utils/basic.js';
export type MediaQueries = EventMediaQuery | RecordingMediaQuery;
export type Query = MediaQueries | FolderViewQuery;
class ViewQuery<T> {
protected _query: T | null = null;
public constructor(query?: T) {
if (query) {
this._query = query;
}
}
public getQuery(): T | null {
return this._query;
}
public setQuery(query: T): this {
this._query = query;
return this;
}
public clone(): this {
return cloneDeep(this);
}
public isEqual(that: Query): boolean {
return isEqual(this._query, that.getQuery());
}
}
class MediaQueryBase<T extends MediaQuery> extends ViewQuery<T[]> {
public getQueryCameraIDs(): Set<string> | null {
if (!this._query) {
return null;
}
const cameraIDs: Set<string> = new Set();
this._query.forEach((query) =>
[...query.cameraIDs].forEach((cameraID) => cameraIDs.add(cameraID)),
);
return cameraIDs;
}
public setQueryCameraIDs(cameraIDs: string | Set<string>): this {
if (!this._query) {
return this;
}
const rewrittenQueries: T[] = [];
this._query.forEach((query) =>
rewrittenQueries.push({ ...query, cameraIDs: setify(cameraIDs) }),
);
this._query = uniqWith(rewrittenQueries, isEqual);
return this;
}
public hasQueriesForCameraIDs(cameraIDs: Set<string>) {
for (const cameraID of cameraIDs) {
if (!this._query?.some((query) => query.cameraIDs.has(cameraID))) {
return false;
}
}
return true;
}
public isSupersetOf(that: MediaQueries): boolean {
// Queries are typically a single item, so quadratic complexity here is
// likely still a lot better than going to the network for a new set of
// query results.
for (const thatQuery of that.getQuery() ?? []) {
let haveMatch = false;
for (const thisQuery of this._query ?? []) {
// Compare the query except the times, and then separately compare the
// times taking into account whether source time is larger than target
// time.
if (
isEqual(
{
...thisQuery,
end: null,
start: null,
},
{ ...thatQuery, end: null, start: null },
) &&
((!thisQuery.start && !thatQuery.start) ||
(thisQuery.start &&
thatQuery.start &&
thisQuery.start <= thatQuery.start)) &&
((!thisQuery.end && !thatQuery.end) ||
(thisQuery.end && thatQuery.end && thisQuery.end >= thatQuery.end))
) {
haveMatch = true;
break;
}
}
if (!haveMatch) {
return false;
}
}
return true;
}
}
export class EventMediaQuery extends MediaQueryBase<EventQuery> {
public convertToClipsQueries(): this {
for (const query of this._query ?? []) {
delete query.hasSnapshot;
query.hasClip = true;
}
return this;
}
}
export class RecordingMediaQuery extends MediaQueryBase<RecordingQuery> {}
export class FolderViewQuery extends ViewQuery<FolderQuery> {}
+556
View File
@@ -0,0 +1,556 @@
import { NonEmptyTuple } from 'type-fest';
import { CameraManager } from '../camera-manager/manager';
import {
CapabilitySearchKeys,
EventQuery,
QueryType,
RecordingQuery,
ReviewQuery,
} from '../camera-manager/types';
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 { VIEW_MEDIA_TYPES, ViewMediaType } from '../types';
import { arrayify } from '../utils/basic';
import { QueryNode, UnifiedQuery } from '../view/unified-query';
interface MediaQueryBuildOptions {
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 {
limit?: number;
}
export interface MediaTypeSpec {
mediaType: 'events' | 'recordings' | 'reviews' | 'folder';
eventsSubtype?: 'clips' | 'snapshots';
}
export const MediaTypeSpec = {
clips: (): MediaTypeSpec => ({ mediaType: 'events', eventsSubtype: 'clips' }),
events: (): MediaTypeSpec => ({ mediaType: 'events' }),
folder: (): MediaTypeSpec => ({ mediaType: 'folder' }),
recordings: (): MediaTypeSpec => ({ mediaType: 'recordings' }),
reviews: (): MediaTypeSpec => ({ mediaType: 'reviews' }),
snapshots: (): MediaTypeSpec => ({ mediaType: 'events', eventsSubtype: 'snapshots' }),
} as const;
/**
* UnifiedQueryBuilder builds UnifiedQuery objects containing QueryNode[]. This
* is the single place where UI concepts (like "clips") are translated to strict
* data queries (EventQuery with hasClip: true).
*
* Related: UnifiedQueryRunner routes them to managers.
*
* Note on code coverage: Throughout this class, _buildBaseQueryNode and similar
* methods return null only when cameraIDs is empty. Public methods guard
* against empty cameraIDs before calling these internal methods, making the
* null branches unreachable. Istanbul ignore comments reference this note.
*/
export class UnifiedQueryBuilder {
private _cameraManager: CameraManager;
private _foldersManager: FoldersManager;
constructor(cameraManager: CameraManager, foldersManager: FoldersManager) {
this._cameraManager = cameraManager;
this._foldersManager = foldersManager;
}
// =========================================================================
// Simple Query Builders
// =========================================================================
public buildClipsQuery(
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
): UnifiedQuery | null {
return this._buildEventsQuery(cameraIDs, { hasClip: true }, options);
}
public buildSnapshotsQuery(
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
): UnifiedQuery | null {
return this._buildEventsQuery(cameraIDs, { hasSnapshot: true }, options);
}
public buildEventsQuery(
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
): UnifiedQuery | null {
return this._buildEventsQuery(cameraIDs, {}, options);
}
private _buildEventsQuery(
cameraIDs: Set<string>,
eventOptions?: { hasClip?: boolean; hasSnapshot?: boolean },
options?: MediaQueryBuildOptions,
): UnifiedQuery | null {
const query = this._addNode(
new UnifiedQuery(),
this._buildEventQueryNode(cameraIDs, eventOptions, options),
);
return query.hasNodes() ? query : null;
}
private _addNode(
query: UnifiedQuery,
nodes?: QueryNode | QueryNode[] | null,
): UnifiedQuery {
if (nodes) {
arrayify(nodes).forEach((node) => query.addNode(node));
}
return query;
}
private _buildEventQueryNode(
cameraIDs: Set<string>,
eventOptions?: { hasClip?: boolean; hasSnapshot?: boolean },
options?: MediaQueryBuildOptions,
): EventQuery | null {
return this._buildBaseQueryNode(QueryType.Event, cameraIDs, options, eventOptions);
}
public buildRecordingsQuery(
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
): UnifiedQuery | null {
if (!cameraIDs.size) {
return null;
}
const query = this._addNode(
new UnifiedQuery(),
this._buildRecordingsQueryNode(cameraIDs, options),
);
/* istanbul ignore next: see class note on code coverage -- @preserve */
return query.hasNodes() ? query : null;
}
private _buildRecordingsQueryNode(
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
): RecordingQuery | null {
return this._buildBaseQueryNode(QueryType.Recording, cameraIDs, options);
}
public buildReviewsQuery(
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions & { reviewed?: boolean },
): UnifiedQuery | null {
const query = this._addNode(
new UnifiedQuery(),
this._buildReviewsQueryNode(cameraIDs, options),
);
return query.hasNodes() ? query : null;
}
private _buildReviewsQueryNode(
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions & { reviewed?: boolean },
): ReviewQuery | null {
return this._buildBaseQueryNode(QueryType.Review, cameraIDs, options, {
reviewed: options?.reviewed,
});
}
private _buildBaseQueryNode(
type: QueryType.Event,
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
extraProps?: { hasClip?: boolean; hasSnapshot?: boolean },
): EventQuery | null;
private _buildBaseQueryNode(
type: QueryType.Recording,
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
): RecordingQuery | null;
private _buildBaseQueryNode(
type: QueryType.Review,
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
extraProps?: { reviewed?: boolean },
): ReviewQuery | null;
private _buildBaseQueryNode(
type: QueryType.Event | QueryType.Recording | QueryType.Review,
cameraIDs: Set<string>,
options?: MediaQueryBuildOptions,
extraProps?: { hasClip?: boolean; hasSnapshot?: boolean; reviewed?: boolean },
): EventQuery | RecordingQuery | ReviewQuery | null {
if (!cameraIDs.size) {
return null;
}
return {
source: QuerySource.Camera,
type,
cameraIDs,
...this._mergeDefaultsForCameras(cameraIDs, type),
...this._extractCommonOptions(options),
...extraProps,
};
}
// =========================================================================
// Filter Query Builders
// =========================================================================
public getAllMediaCapableCameraIDs(): Set<string> {
return this._cameraManager.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'],
});
}
public buildFilterQuery(
cameraIDs: Set<string> | null,
mediaTypes: Set<ViewMediaType> | null,
options?: FilterQueryBuildOptions,
): UnifiedQuery | null {
const query = new UnifiedQuery();
// Default to all cameras if none specified
const effectiveCameraIDs = cameraIDs?.size
? cameraIDs
: this.getAllMediaCapableCameraIDs();
// Default to all media types if none specified
const effectiveMediaTypes = mediaTypes?.size
? mediaTypes
: new Set<ViewMediaType>(VIEW_MEDIA_TYPES);
// Build camera-based media queries (when cameras are available)
if (effectiveCameraIDs.size) {
for (const mediaType of effectiveMediaTypes) {
const node = this._buildFilterQueryNode(mediaType, effectiveCameraIDs, options);
/* istanbul ignore next: see class note on code coverage -- @preserve */
if (node) {
query.addNode(node);
}
}
}
/* istanbul ignore next: see class note on code coverage -- @preserve */
return query.hasNodes() ? query : null;
}
private _buildFilterQueryNode(
mediaType: ViewMediaType,
cameraIDs: Set<string>,
options?: FilterQueryBuildOptions,
): 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, {
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, {
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;
}
}
}
// =========================================================================
// Folder Query Builders
// =========================================================================
public buildFolderQueryWithPath(
folder: FolderConfig,
path: NonEmptyTuple<FolderPathComponent>,
options?: QueryLimitOptions,
): UnifiedQuery {
const query = new UnifiedQuery();
const folderQuery: FolderQuery = {
source: QuerySource.Folder,
folder,
path,
...(options?.limit !== undefined && { limit: options.limit }),
};
query.addNode(folderQuery);
return query;
}
public buildDefaultFolderQuery(
folderID?: string,
options?: QueryLimitOptions,
): UnifiedQuery | null {
const query = new UnifiedQuery();
this._addNode(query, this._buildFolderQueryNode(folderID, options));
return query.hasNodes() ? query : null;
}
private _buildFolderQueryNodesForCameras(
cameraIDs: Set<string>,
options?: QueryLimitOptions,
): QueryNode[] {
const nodes: QueryNode[] = [];
for (const cameraID of cameraIDs) {
const mediaConfig = this._cameraManager
.getStore()
.getCameraConfig(cameraID)?.media;
for (const folderID of mediaConfig?.folders ?? [undefined]) {
const node = this._buildFolderQueryNode(folderID, options);
if (node) {
nodes.push(node);
}
}
}
return nodes;
}
private _buildFolderQueryNode(
folderID?: string,
options?: QueryLimitOptions,
): QueryNode | null {
const folder = this._foldersManager.getFolder(folderID);
const params = folder && this._foldersManager.getDefaultQueryParameters(folder);
return params
? {
...params,
...options,
}
: null;
}
// =========================================================================
// Default Query Builders
// =========================================================================
public buildDefaultCameraQuery(
cameraID?: string,
options?: QueryLimitOptions,
): UnifiedQuery | null {
const cameraIDs = cameraID
? this._cameraManager.getStore().getAllDependentCameras(cameraID)
: this._cameraManager.getStore().getCameraIDs();
const query = new UnifiedQuery();
for (const cameraID of cameraIDs) {
this._addNode(query, this._buildDefaultCameraQueryNodes(cameraID, options));
}
return query.hasNodes() ? query : null;
}
private _buildDefaultCameraQueryNodes(
cameraID: string,
options?: QueryLimitOptions,
): QueryNode | QueryNode[] | null {
const mediaConfig = this._cameraManager.getStore().getCameraConfig(cameraID)?.media;
const spec = this._resolveMediaTypeSpec(
cameraID,
mediaConfig?.type,
mediaConfig?.events_type,
);
if (!spec) {
return null;
}
return this._buildQueryNodesCapabilityUnchecked(spec, new Set([cameraID]), {
limit: options?.limit,
});
}
// =========================================================================
// Advanced Query Builders
// =========================================================================
public buildCameraMediaQuery(
spec: MediaTypeSpec,
options?: QueryLimitOptions & {
cameraID?: string;
},
): 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;
}
const cameraIDs = options?.cameraID
? this._cameraManager
.getStore()
.getAllDependentCameras(options.cameraID, neededCapability)
: this._cameraManager.getStore().getCameraIDsWithCapability(neededCapability);
const query = new UnifiedQuery();
this._addNode(
query,
this._buildQueryNodesCapabilityUnchecked(spec, cameraIDs, options),
);
return query.hasNodes() ? query : null;
}
private _resolveMediaTypeSpec(
cameraID: string,
type?: CameraMediaType,
eventsType?: 'clips' | 'snapshots' | 'all',
): MediaTypeSpec | null {
const capabilities = this._cameraManager.getCameraCapabilities(cameraID);
if (!capabilities) {
return null;
}
const hasReviews = capabilities.has('reviews');
const hasClips = capabilities.has('clips');
const hasSnapshots = capabilities.has('snapshots');
const hasRecordings = capabilities.has('recordings');
if (!type || type === 'auto') {
if (hasReviews) {
return MediaTypeSpec.reviews();
}
if (hasClips) {
return MediaTypeSpec.clips();
}
if (hasSnapshots) {
return MediaTypeSpec.snapshots();
}
if (hasRecordings) {
return MediaTypeSpec.recordings();
}
return null;
}
switch (type) {
case 'recordings':
return hasRecordings ? MediaTypeSpec.recordings() : null;
case 'reviews':
return hasReviews ? MediaTypeSpec.reviews() : null;
case 'folder':
return MediaTypeSpec.folder();
case 'events':
if (eventsType === 'all' && hasClips && hasSnapshots) {
return MediaTypeSpec.events();
}
if ((eventsType === 'all' || eventsType === 'clips') && hasClips) {
return MediaTypeSpec.clips();
}
if ((eventsType === 'all' || eventsType === 'snapshots') && hasSnapshots) {
return MediaTypeSpec.snapshots();
}
return null;
}
}
private _buildQueryNodesCapabilityUnchecked(
spec: MediaTypeSpec,
cameraIDs: Set<string>,
options?: QueryLimitOptions,
): QueryNode | QueryNode[] | null {
switch (spec.mediaType) {
case 'events':
switch (spec.eventsSubtype) {
case 'clips':
return this._buildEventQueryNode(cameraIDs, { hasClip: true }, options);
case 'snapshots':
return this._buildEventQueryNode(cameraIDs, { hasSnapshot: true }, options);
default:
return this._buildEventQueryNode(cameraIDs, {}, options);
}
case 'recordings':
return this._buildRecordingsQueryNode(cameraIDs, options);
case 'reviews':
return this._buildReviewsQueryNode(cameraIDs, options);
case 'folder': {
return this._buildFolderQueryNodesForCameras(cameraIDs, options);
}
}
}
// =========================================================================
// Private Utility Helpers
// =========================================================================
private _extractCommonOptions(options?: MediaQueryBuildOptions): {
start?: Date;
end?: Date;
limit?: number;
} {
return {
...(options?.start && { start: options.start }),
...(options?.end && { end: options.end }),
...(options?.limit !== undefined && { limit: options.limit }),
};
}
private _mergeDefaultsForCameras(
cameraIDs: Set<string>,
queryType: QueryType,
): { what?: Set<string>; where?: Set<string> } {
const what: string[] = [];
const where: string[] = [];
for (const cameraID of cameraIDs) {
const defaults = this._cameraManager.getDefaultQueryParameters(
cameraID,
queryType,
);
if (defaults?.what) {
what.push(...defaults.what);
}
if (defaults?.where) {
where.push(...defaults.where);
}
}
return {
...(what.length && { what: new Set(what) }),
...(where.length && { where: new Set(where) }),
};
}
}
+114
View File
@@ -0,0 +1,114 @@
import { CameraManager } from '../camera-manager/manager';
import { FoldersManager } from '../card-controller/folders/manager';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { ViewItem } from '../view/item';
import { UnifiedQuery } from '../view/unified-query';
interface QueryRunnerOptions {
useCache?: boolean;
}
/**
* UnifiedQueryRunner routes UnifiedQuery nodes to the appropriate managers.
*/
export class UnifiedQueryRunner {
private _cameraManager: CameraManager;
private _foldersManager: FoldersManager;
private _conditionStateManager: ConditionStateManagerReadonlyInterface;
constructor(
cameraManager: CameraManager,
foldersManager: FoldersManager,
conditionStateManager: ConditionStateManagerReadonlyInterface,
) {
this._cameraManager = cameraManager;
this._foldersManager = foldersManager;
this._conditionStateManager = conditionStateManager;
}
public async execute(
query: UnifiedQuery,
options?: QueryRunnerOptions,
): Promise<ViewItem[]> {
const allItems: ViewItem[] = [];
// Execute media queries
const mediaQueries = query.getMediaQueries();
if (mediaQueries.length > 0) {
const items = await this._cameraManager.executeMediaQueries(mediaQueries, {
useCache: options?.useCache,
});
allItems.push(...(items ?? []));
}
// Execute folder queries
const folderQueries = query.getFolderQueries();
for (const folderQuery of folderQueries) {
const items = await this._foldersManager.expandFolder(
folderQuery,
this._conditionStateManager.getState(),
{ useCache: options?.useCache },
);
allItems.push(...(items ?? []));
}
return allItems;
}
public areResultsFresh(resultsTimestamp: Date, query: UnifiedQuery): boolean {
const mediaQueries = query.getMediaQueries();
if (
mediaQueries.length > 0 &&
!this._cameraManager.areMediaQueriesResultsFresh(resultsTimestamp, mediaQueries)
) {
return false;
}
const folderQueries = query.getFolderQueries();
for (const folderQuery of folderQueries) {
if (!this._foldersManager.areResultsFresh(resultsTimestamp, folderQuery)) {
return false;
}
}
return true;
}
/**
* Extend a query by fetching additional media in a direction (earlier/later).
*/
public async extend(
query: UnifiedQuery,
existingResults: ViewItem[],
direction: 'earlier' | 'later',
options?: QueryRunnerOptions,
): Promise<{ query: UnifiedQuery; results: ViewItem[] } | null> {
const mediaQueries = query.getMediaQueries();
const nonExtendableQueries = query.getNonMediaQueries();
if (mediaQueries.length === 0) {
return null;
}
const extension = await this._cameraManager.extendMediaQueries(
mediaQueries,
existingResults,
direction,
{ useCache: options?.useCache },
);
if (!extension) {
return null;
}
const extendedQuery = new UnifiedQuery();
for (const mediaQuery of extension.queries) {
extendedQuery.addNode(mediaQuery);
}
for (const node of nonExtendableQueries) {
extendedQuery.addNode(node);
}
return { query: extendedQuery, results: extension.results };
}
}
+57
View File
@@ -0,0 +1,57 @@
import { CameraQueryClassifier } from '../camera-manager/manager';
import { QuerySource } from '../query-source';
import { UnifiedQuery } from './unified-query';
interface RebuildOptions {
start?: Date;
end?: Date;
limit?: number;
}
/**
* Static utility methods for cloning and transforming existing UnifiedQuery
* objects. Unlike UnifiedQueryBuilder, these don't require state (e.g. a
* CameraManager).
*/
export class UnifiedQueryTransformer {
static stripTimeRange(query: UnifiedQuery): UnifiedQuery {
const nodes = query.getNodes().map((node) => {
if (node.source !== QuerySource.Camera) {
return node;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { start, end, ...rest } = node;
return rest;
});
return new UnifiedQuery(nodes);
}
static rebuildQuery(query: UnifiedQuery, options: RebuildOptions): UnifiedQuery {
const commonOptions = {
...(options?.start && { start: options.start }),
...(options?.end && { end: options.end }),
...(options?.limit !== undefined && { limit: options.limit }),
};
const nodes = query.getNodes().map((node) => {
if (node.source === QuerySource.Camera) {
return {
...node,
...commonOptions,
};
}
return node;
});
return new UnifiedQuery(nodes);
}
static convertToClips(query: UnifiedQuery): UnifiedQuery {
const nodes = query
.getNodes()
.map((node) =>
node.source === QuerySource.Camera && CameraQueryClassifier.isEventQuery(node)
? { ...node, hasClip: true, hasSnapshot: undefined }
: node,
);
return new UnifiedQuery(nodes);
}
}
+162
View File
@@ -0,0 +1,162 @@
import cloneDeep from 'lodash-es/cloneDeep.js';
import isEqual from 'lodash-es/isEqual.js';
import omit from 'lodash-es/omit.js';
import { EventQuery, MediaQuery, QueryType } from '../camera-manager/types.js';
import { FolderQuery } from '../card-controller/folders/types.js';
import { QuerySource } from '../query-source.js';
import { ViewMediaType } from '../types.js';
export type QueryNode = MediaQuery | FolderQuery;
export class UnifiedQuery {
private _nodes: QueryNode[];
constructor(nodes?: QueryNode[]) {
this._nodes = nodes ? [...nodes] : [];
}
public addNode(node: QueryNode): this {
this._nodes.push(node);
return this;
}
public getNodes(): QueryNode[] {
return this._nodes;
}
public getNodeCount(): number {
return this._nodes.length;
}
public hasNodes(): boolean {
return this._nodes.length > 0;
}
public getMediaQueries<T extends MediaQuery = MediaQuery>(options?: {
cameraID?: string;
type?: QueryType;
}): T[] {
return this._nodes.filter((node): node is T => {
if (!this._isMediaQuery(node)) {
return false;
}
if (options?.cameraID && !node.cameraIDs.has(options.cameraID)) {
return false;
}
if (options?.type && node.type !== options.type) {
return false;
}
return true;
});
}
public getFolderQueries(folderID?: string): FolderQuery[] {
return this._nodes.filter(
(node): node is FolderQuery =>
this._isFolderQuery(node) && (!folderID || node.folder.id === folderID),
);
}
public getNonMediaQueries(): QueryNode[] {
return this._nodes.filter((node) => !this._isMediaQuery(node));
}
public hasMediaQueriesOfType(type: QueryType): boolean {
return this._nodes.some((node) => this._isMediaQuery(node) && node.type === type);
}
public getAllCameraIDs(): Set<string> {
const cameraIDs = new Set<string>();
for (const node of this._nodes) {
if (this._isMediaQuery(node)) {
for (const id of node.cameraIDs) {
cameraIDs.add(id);
}
}
}
return cameraIDs;
}
public getAllMediaTypes(): Set<ViewMediaType> {
const types = new Set<ViewMediaType>();
for (const node of this._nodes) {
if (this._isMediaQuery(node)) {
if (node.type === QueryType.Event) {
const eventQuery = node as EventQuery;
if (eventQuery.hasClip) {
types.add('clips');
}
if (eventQuery.hasSnapshot) {
types.add('snapshots');
}
} else if (node.type === QueryType.Recording) {
types.add('recordings');
} else if (node.type === QueryType.Review) {
types.add('reviews');
}
}
}
return types;
}
public clone(): UnifiedQuery {
return new UnifiedQuery(cloneDeep(this._nodes));
}
public isEqual(that: UnifiedQuery): boolean {
return isEqual(this._nodes, that._nodes);
}
/**
* Check if this query is a superset of another query.
* A query is a superset if it covers at least all the queries of the other.
* For media queries, this means same cameras/type and time range encompasses.
*/
public isSupersetOf(that: UnifiedQuery): boolean {
const nodeCovers = (thisNode: QueryNode, thatNode: QueryNode): boolean => {
if (this._isMediaQuery(thatNode) && this._isMediaQuery(thisNode)) {
const stripTimeRange = (query: MediaQuery) => omit(query, ['start', 'end']);
return (
isEqual(stripTimeRange(thisNode), stripTimeRange(thatNode)) &&
timeRangeCovers(thisNode, thatNode)
);
}
return isEqual(thisNode, thatNode);
};
const timeRangeCovers = (
source: { start?: Date; end?: Date },
target: { start?: Date; end?: Date },
): boolean => {
const boundaryCovers = (
compare: (s: Date, t: Date) => boolean,
sourceBound?: Date,
targetBound?: Date,
): boolean => {
if (!targetBound) {
// Unbounded target requires unbounded source
return !sourceBound;
}
// Bounded target: source must be unbounded or extend at least as far
return !sourceBound || compare(sourceBound, targetBound);
};
return (
boundaryCovers((s, t) => s <= t, source.start, target.start) &&
boundaryCovers((s, t) => s >= t, source.end, target.end)
);
};
return that._nodes.every((thatNode) =>
this._nodes.some((thisNode) => nodeCovers(thisNode, thatNode)),
);
}
private _isMediaQuery(node: QueryNode): node is MediaQuery {
return node.source === QuerySource.Camera;
}
private _isFolderQuery(node: QueryNode): node is FolderQuery {
return node.source === QuerySource.Folder;
}
}
+5 -1
View File
@@ -32,6 +32,8 @@ export const getCameraIDsForViewName = (
case 'snapshots':
case 'recording':
case 'recordings':
case 'review':
case 'reviews':
const options: CapabilitySearchOptions = {
inclusive: viewName !== 'live',
};
@@ -42,7 +44,9 @@ export const getCameraIDsForViewName = (
? 'snapshots'
: viewName === 'recording'
? 'recordings'
: viewName;
: viewName === 'review'
? 'reviews'
: viewName;
return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
: cameraManager.getStore().getCameraIDsWithCapability(capability, options);
+16 -13
View File
@@ -2,8 +2,9 @@ import { merge } from 'lodash-es';
import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { ViewDisplayMode } from '../config/schema/common/display';
import { Query } from './query';
import { ViewMediaType } from '../types';
import { QueryResults } from './query-results';
import { UnifiedQuery } from './unified-query';
declare module 'view' {
interface ViewContext {
@@ -16,7 +17,7 @@ declare module 'view' {
interface ViewEvolveParameters {
view?: AdvancedCameraCardView;
camera?: string;
query?: Query | null;
query?: UnifiedQuery | null;
queryResults?: QueryResults | null;
context?: ViewContext | null;
displayMode?: ViewDisplayMode | null;
@@ -37,7 +38,7 @@ export const mergeViewContext = (
export class View {
public view: AdvancedCameraCardView;
public camera: string;
public query: Query | null;
public query: UnifiedQuery | null;
public queryResults: QueryResults | null;
public context: ViewContext | null;
public displayMode: ViewDisplayMode | null;
@@ -123,10 +124,12 @@ export class View {
}
/**
* Determine if a view is a media gallery.
* Determine if a view is a gallery.
*/
public isMediaGalleryView(): boolean {
return ['clips', 'folders', 'snapshots', 'recordings'].includes(this.view);
public isGalleryView(): boolean {
return ['clips', 'folders', 'snapshots', 'recordings', 'reviews'].includes(
this.view,
);
}
/**
@@ -145,19 +148,16 @@ export class View {
* Determine if a view is for the media viewer.
*/
public isViewerView(): boolean {
return ['folder', 'media', 'clip', 'snapshot', 'recording'].includes(this.view);
return ['folder', 'media', 'clip', 'snapshot', 'recording', 'review'].includes(
this.view,
);
}
public supportsMultipleDisplayModes(): boolean {
return this.isViewerView() || this.is('live');
}
/**
* Get the default media type for this view if available.
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
* (`null`).
*/
public getDefaultMediaType(): 'clips' | 'snapshots' | 'recordings' | null {
public getDefaultMediaType(): ViewMediaType | null {
if (['clip', 'clips'].includes(this.view)) {
return 'clips';
}
@@ -167,6 +167,9 @@ export class View {
if (['recording', 'recordings'].includes(this.view)) {
return 'recordings';
}
if (['review', 'reviews'].includes(this.view)) {
return 'reviews';
}
return null;
}