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:
@@ -0,0 +1,132 @@
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { View } from '../../view/view';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
interface GalleryViewContext {
|
||||
// The gallery view type the user navigated from (when in viewer). Used to
|
||||
// determine if query/results should be preserved when returning.
|
||||
originView?: AdvancedCameraCardView;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
gallery?: GalleryViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
const GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
// The minimum width of a folder thumbnail with details enabled.
|
||||
const FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
|
||||
|
||||
export class GalleryController {
|
||||
private _host: HTMLElement;
|
||||
private _items: ViewItem[] | null = null;
|
||||
private _foldersOnly = false;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public getItems(): ViewItem[] | null {
|
||||
return this._items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set items from view query results.
|
||||
* Media is reversed so newest appears first in the gallery.
|
||||
*/
|
||||
public setItemsFromView(newView?: View | null, oldView?: View | null): void {
|
||||
const newResults = newView?.queryResults?.getResults() ?? null;
|
||||
if (newResults === null) {
|
||||
this._items = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._items || oldView?.queryResults?.getResults() !== newResults) {
|
||||
// Gallery places the most recent media at the top (the query results
|
||||
// place the most recent media at the end for use in the viewer).
|
||||
this._items = [...newResults].reverse();
|
||||
}
|
||||
|
||||
this._foldersOnly = this._items?.every((item) => ViewItemClassifier.isFolder(item));
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
if (!thumbnailConfig) {
|
||||
return THUMBNAIL_WIDTH_DEFAULT;
|
||||
}
|
||||
if (!thumbnailConfig.show_details) {
|
||||
return thumbnailConfig.size;
|
||||
}
|
||||
|
||||
// Use smaller width when all items are folders
|
||||
return this._foldersOnly
|
||||
? FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public async extend(
|
||||
runner: UnifiedQueryRunner,
|
||||
viewManagerEpoch: ViewManagerEpoch,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
const view = viewManagerEpoch.manager.getView();
|
||||
if (!view?.query || !view?.queryResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingResults = view.queryResults.getResults();
|
||||
if (!existingResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extended: { query: UnifiedQuery; results: ViewItem[] } | null;
|
||||
try {
|
||||
extended = await runner.extend(view.query, existingResults, direction, {
|
||||
useCache,
|
||||
});
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extended) {
|
||||
viewManagerEpoch.manager.setViewByParameters({
|
||||
baseView: view,
|
||||
params: {
|
||||
query: extended.query,
|
||||
queryResults: new QueryResults({
|
||||
results: extended.results,
|
||||
}).selectResultIfFound(
|
||||
(item) => item === view.queryResults?.getSelectedResult(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { ViewManagerInterface } from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a (folder) thumbnail with details enabled. This is
|
||||
// shorter than for regular camera media as this will consist of just a name.
|
||||
export const FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
|
||||
|
||||
export class FolderGalleryController {
|
||||
private _host: HTMLElement;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
item: ViewItem,
|
||||
ev: Event,
|
||||
foldersManager?: FoldersManager,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
if (ViewItemClassifier.isMedia(item)) {
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults
|
||||
?.clone()
|
||||
.selectResultIfFound((result) => result === item),
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
ViewItemClassifier.isFolder(item) &&
|
||||
QueryClassifier.isFolderQuery(view.query)
|
||||
) {
|
||||
const rawQuery = view.query.getQuery();
|
||||
if (!rawQuery || !foldersManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuery = foldersManager.generateChildFolderQuery(rawQuery, item);
|
||||
if (!newQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: view.query.clone().setQuery(newQuery),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../../camera-manager/manager';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../../camera-manager/types';
|
||||
import {
|
||||
ViewManagerEpoch,
|
||||
ViewManagerInterface,
|
||||
} from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../../view/query';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { View } from '../../view/view';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
export const MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
export class MediaGalleryController {
|
||||
private _host: HTMLElement;
|
||||
private _media: ViewItem[] | null = null;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public getMedia(): ViewItem[] | null {
|
||||
return this._media;
|
||||
}
|
||||
|
||||
public setMediaFromView(newView?: View | null, oldView?: View | null): void {
|
||||
const newResults = newView?.queryResults?.getResults() ?? null;
|
||||
if (newResults === null) {
|
||||
this._media = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._media || oldView?.queryResults?.getResults() !== newResults) {
|
||||
// Media gallery places the most recent media at the top (the query
|
||||
// results place the most recent media at the end for use in the viewer).
|
||||
// This is copied to a new array to avoid reversing the query results in
|
||||
// place.
|
||||
this._media = [...newResults].reverse();
|
||||
}
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public async extendMediaGallery(
|
||||
cameraManager: CameraManager,
|
||||
viewManagerEpoch: ViewManagerEpoch,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
const view = viewManagerEpoch.manager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = view.query;
|
||||
const existingMedia = view.queryResults?.getResults();
|
||||
if (!existingMedia || !query || !QueryClassifier.isMediaQuery(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawQueries = query.getQuery() ?? null;
|
||||
if (!rawQueries) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
|
||||
try {
|
||||
extension = await cameraManager.extendMediaQueries<MediaQuery>(
|
||||
rawQueries,
|
||||
existingMedia,
|
||||
direction,
|
||||
{
|
||||
useCache: useCache,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
const newMediaQueries = QueryClassifier.isEventQuery(query)
|
||||
? new EventMediaQuery(extension.queries as EventQuery[])
|
||||
: QueryClassifier.isRecordingQuery(query)
|
||||
? new RecordingMediaQuery(extension.queries as RecordingQuery[])
|
||||
: /* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
null;
|
||||
|
||||
/* istanbul ignore else: this path cannot be reached, as we explicitly
|
||||
check for media queries above -- @preserve */
|
||||
if (newMediaQueries) {
|
||||
viewManagerEpoch.manager.setViewByParameters({
|
||||
baseView: view,
|
||||
params: {
|
||||
query: newMediaQueries,
|
||||
queryResults: new QueryResults({
|
||||
results: extension.results,
|
||||
}).selectResultIfFound(
|
||||
(media) => media === view.queryResults?.getSelectedResult(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
reversedIndex: number,
|
||||
ev: Event,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view || !this._media?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults?.clone().selectIndex(
|
||||
// Media in the gallery is reversed vs the queryResults (see
|
||||
// note above).
|
||||
this._media.length - reversedIndex - 1,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user