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
+81 -1
View File
@@ -35,6 +35,7 @@ import {
CameraEndpointsContext,
CameraManagerCameraMetadata,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -49,6 +50,7 @@ import {
PartialQueryConcreteType,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
@@ -60,6 +62,8 @@ import {
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
ReviewQuery,
ReviewQueryResults,
} from './types.js';
export class CameraQueryClassifier {
@@ -83,6 +87,11 @@ export class CameraQueryClassifier {
): query is MediaMetadataQuery {
return query.type === QueryType.MediaMetadata;
}
public static isReviewQuery(
query: CameraQuery | PartialCameraQuery,
): query is ReviewQuery {
return query.type === QueryType.Review;
}
}
export class QueryResultClassifier {
@@ -106,9 +115,14 @@ export class QueryResultClassifier {
): queryResults is MediaMetadataQueryResults {
return queryResults?.type === QueryResultsType.MediaMetadata;
}
public static isReviewQueryResult(
queryResults?: QueryResults | null,
): queryResults is ReviewQueryResults {
return queryResults?.type === QueryResultsType.Review;
}
}
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewItem[];
}
@@ -308,6 +322,17 @@ export class CameraManager {
});
}
public getDefaultQueryParameters(
cameraID: string,
queryType: QueryType,
): DefaultQueryParameters {
const camera = this._store.getCamera(cameraID);
if (!camera) {
return {};
}
return camera.getEngine().getDefaultQueryParameters(camera, queryType);
}
public generateDefaultRecordingQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialRecordingQuery,
@@ -328,6 +353,16 @@ export class CameraManager {
});
}
public generateDefaultReviewQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialReviewQuery,
): ReviewQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
type: QueryType.Review,
...partialQuery,
});
}
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
cameraIDs: string | Set<string>,
partialQuery: PQT,
@@ -356,6 +391,12 @@ export class CameraManager {
cameraIDs,
partialQuery,
);
} else if (CameraQueryClassifier.isReviewQuery(partialQuery)) {
queries = engine.generateDefaultReviewQuery(
this._store,
cameraIDs,
partialQuery,
);
}
for (const query of queries ?? []) {
@@ -568,6 +609,33 @@ export class CameraManager {
);
}
public async reviewMedia(media: ViewMedia, reviewed: boolean): Promise<void> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine || !hass) {
return;
}
const queryStartTime = new Date();
await this._requestLimit.add(() =>
engine.reviewMedia(hass, cameraConfig, media, reviewed),
);
log(
this._api.getConfigManager().getCardWideConfig(),
'Advanced Camera Card CameraManager review media request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Media:',
media.getID(),
', Reviewed:',
reviewed,
')',
);
}
public areMediaQueriesResultsFresh<T extends MediaQuery>(
resultsTimestamp: Date,
queries: T[] | null,
@@ -667,6 +735,13 @@ export class CameraManager {
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (CameraQueryClassifier.isReviewQuery(query)) {
engineResult = (await engine.getReviews(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
}
engineResult?.forEach((value, key) => results.set(key, value));
@@ -739,6 +814,11 @@ export class CameraManager {
QueryResultClassifier.isRecordingQueryResult(result)
) {
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
} else if (
CameraQueryClassifier.isReviewQuery(query) &&
QueryResultClassifier.isReviewQueryResult(result)
) {
media = engine.generateMediaFromReviews(hass, this._store, query, result);
}
if (media) {
mediaArray.push(...media);