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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user