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
+147 -3
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { ViewFolder, ViewItem } from '../../src/view/item';
import { QueryResults } from '../../src/view/query-results';
import { createFolder, generateViewMediaArray } from '../test-utils';
import { createFolder, generateViewMediaArray, TestViewMedia } from '../test-utils';
describe('dispatchViewContextChangeEvent', () => {
beforeEach(() => {
@@ -281,7 +281,7 @@ describe('dispatchViewContextChangeEvent', () => {
});
it('should keep selected folder in main slice but not camera slices', () => {
const folder = new ViewFolder(createFolder());
const folder = new ViewFolder(createFolder(), []);
const results = new QueryResults({
results: [
folder,
@@ -295,4 +295,148 @@ describe('dispatchViewContextChangeEvent', () => {
expect(results.getSelectedResult()).toBe(folder);
expect(results.getSelectedResult('camera.office')).not.toBe(folder);
});
describe('removeItem', () => {
it('should remove item from results', () => {
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: testResults });
const itemToRemove = testResults[50];
expect(results.getResults()?.includes(itemToRemove)).toBeTruthy();
expect(results.getResultsCount()).toBe(200);
results.removeItem(itemToRemove);
expect(results.getResults()?.includes(itemToRemove)).toBeFalsy();
expect(results.getResultsCount()).toBe(199);
});
it('should not remove item that is not in results', () => {
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: testResults });
const outsideItem = generateViewMediaArray({ cameraIDs: ['other'] })[0];
const countBefore = results.getResultsCount();
results.removeItem(outsideItem);
expect(results.getResultsCount()).toBe(countBefore);
});
it('should adjust selection when removing selected item', () => {
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: testResults, selectedIndex: 50 });
const itemToRemove = testResults[50];
results.removeItem(itemToRemove);
// After removing selected item at index 50, Math.min(50, 199-1) = 50
// which is still valid in the new 199-element array
expect(results.getSelectedIndex()).toBe(50);
});
it('should adjust selection when removing item before selected', () => {
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: testResults, selectedIndex: 50 });
const itemToRemove = testResults[10];
results.removeItem(itemToRemove);
// Selection should decrement
expect(results.getSelectedIndex()).toBe(49);
});
it('should not change selection when removing item after selected', () => {
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: testResults, selectedIndex: 50 });
const itemToRemove = testResults[100];
results.removeItem(itemToRemove);
// Selection should stay the same
expect(results.getSelectedIndex()).toBe(50);
});
it('should set selection to null when removing last remaining selected item', () => {
const testResults = generateViewMediaArray({ cameraIDs: ['camera'], count: 1 });
const results = new QueryResults({ results: testResults, selectedIndex: 0 });
results.removeItem(testResults[0]);
// Selection should be null when no items remain
expect(results.getSelectedIndex()).toBeNull();
});
it('should handle removing folder item (non-media)', () => {
const folder = new ViewFolder(createFolder(), []);
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: [folder, ...testResults] });
const countBefore = results.getResultsCount();
results.removeItem(folder);
expect(results.getResultsCount()).toBe(countBefore - 1);
});
});
describe('replaceItem', () => {
it('should replace item in slice', () => {
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: testResults });
const slice = results.getSlice('office');
assert(slice);
const oldItem = slice.getResults()[42];
const newItem = oldItem.clone();
expect(slice?.replaceItem(oldItem, newItem)).toBeTruthy();
expect(slice?.getResults()[42]).toBe(newItem);
});
it('should fail to replace item not in camera slice', () => {
const results = new QueryResults({ results: generateViewMediaArray() });
const slice = results.getSlice('office');
assert(slice);
const foreignItem = new TestViewMedia({
cameraID: 'other',
});
const newItem = foreignItem.clone();
expect(slice.replaceItem(foreignItem, newItem)).toBeFalsy();
});
it('should replace item in main slice', () => {
const testResults = generateViewMediaArray();
const results = new QueryResults({ results: testResults });
const oldItem = testResults[42];
const newItem = oldItem.clone();
expect(results.replaceItem(oldItem, newItem)).toBe(results);
expect(results.getResults()?.[42]).toBe(newItem);
expect(results.getSlice('kitchen')?.getResults().includes(newItem)).toBeTruthy();
expect(results.getSlice('kitchen')?.getResults().includes(oldItem)).toBeFalsy();
});
it('should fail to replace item not in main slice', () => {
const results = new QueryResults({ results: generateViewMediaArray() });
const outsideItem = generateViewMediaArray({ cameraIDs: ['other'] })[0];
const newItem = outsideItem.clone();
expect(results.replaceItem(outsideItem, newItem)).toBe(results);
});
it('should replace folder in main slice', () => {
const folder = new ViewFolder(createFolder(), []);
const results = new QueryResults({ results: [folder] });
const newFolder = folder.clone();
expect(results.replaceItem(folder, newFolder)).toBe(results);
expect(results.getResults()?.[0]).toBe(newFolder);
});
});
});