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
+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()));