Fetch metadata from engine.
This commit is contained in:
@@ -62,6 +62,13 @@ export class CameraManagerEngineFactory {
|
||||
}
|
||||
output.get(engine)?.add(cameraID);
|
||||
}
|
||||
return output;
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public getAllEngines(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): CameraManagerEngine[] | null {
|
||||
const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys()));
|
||||
return engines ? [...engines.keys()] : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadata,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
@@ -75,9 +76,7 @@ export interface CameraManagerEngine {
|
||||
favorite: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
getQueryResultMaxAge(
|
||||
query: DataQuery
|
||||
): number | null;
|
||||
getQueryResultMaxAge(query: DataQuery): number | null;
|
||||
|
||||
getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
@@ -85,4 +84,9 @@ export interface CameraManagerEngine {
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null>;
|
||||
|
||||
getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<MediaMetadata | null>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
FrigateEventQueryResults,
|
||||
FrigateRecordingQueryResults,
|
||||
FrigateRecordingSegmentsQueryResults,
|
||||
MediaMetadata,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import { FrigateRecording } from './types';
|
||||
import {
|
||||
getEvents,
|
||||
getEventSummary,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
NativeFrigateEventQuery,
|
||||
@@ -44,7 +46,7 @@ import {
|
||||
} from './requests';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { runWhenIdleIfSupported } from '../../utils/basic';
|
||||
import { allPromises, runWhenIdleIfSupported } from '../../utils/basic';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import { sum } from 'lodash-es';
|
||||
import { FrigateViewMediaClassifier } from './media-classifier';
|
||||
@@ -214,10 +216,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
|
||||
protected _buildInstanceToCameraIDMapFromQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: DataQuery,
|
||||
cameraIDs: Set<string>,
|
||||
): Map<string, Set<string>> {
|
||||
const output: Map<string, Set<string>> = new Map();
|
||||
for (const cameraID of query.cameraIDs) {
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
const clientID = cameraConfig?.frigate.client_id;
|
||||
if (clientID) {
|
||||
@@ -295,7 +297,10 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
// Frigate allows multiple cameras to be searched for events in a single
|
||||
// query. Break them down into groups of cameras per Frigate instance, then
|
||||
// query once per instance for all cameras in that instance.
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(cameras, query);
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(
|
||||
cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(instances.keys()).map((instanceID) =>
|
||||
@@ -611,6 +616,56 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
return cameraConfig;
|
||||
}
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<MediaMetadata | null> {
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(
|
||||
cameras,
|
||||
new Set(cameras.keys()),
|
||||
);
|
||||
|
||||
const processQuery = async (
|
||||
instanceID: string,
|
||||
cameraIDs: Set<string>,
|
||||
): Promise<void> => {
|
||||
const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs);
|
||||
for (const entry of await getEventSummary(hass, instanceID)) {
|
||||
if (!cameraNames.has(entry.camera)) {
|
||||
// If this entry applies to a camera that *is* in this Frigate
|
||||
// instance, but is *not* a configured camera in the card, skip it.
|
||||
continue;
|
||||
}
|
||||
if (entry.label) {
|
||||
what.add(entry.label);
|
||||
}
|
||||
if (entry.zones.length) {
|
||||
entry.zones.forEach(where.add, where);
|
||||
}
|
||||
if (entry.day) {
|
||||
days.add(entry.day);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await allPromises([...instances.entries()], ([instanceID, cameraIDs]) =>
|
||||
processQuery(instanceID, cameraIDs),
|
||||
);
|
||||
|
||||
if (!what.size && !where.size && !days.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect recording segments that no longer feature in the recordings
|
||||
* returned by the Frigate backend.
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { localize } from '../../localize/localize';
|
||||
import {
|
||||
FrigateCardError,
|
||||
RecordingSegment,
|
||||
} from '../../types';
|
||||
import { FrigateCardError, RecordingSegment } from '../../types';
|
||||
import { homeAssistantWSRequest } from '../../utils/ha';
|
||||
import { FrigateEvent, frigateEventsSchema, recordingSegmentsSchema, RecordingSummary, recordingSummarySchema, RetainResult, retainResultSchema } from './types';
|
||||
import {
|
||||
EventSummary,
|
||||
eventSummarySchema,
|
||||
FrigateEvent,
|
||||
frigateEventsSchema,
|
||||
recordingSegmentsSchema,
|
||||
RecordingSummary,
|
||||
recordingSummarySchema,
|
||||
RetainResult,
|
||||
retainResultSchema,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Get the recordings summary. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param client_id The Frigate client_id.
|
||||
* @param clientID The Frigate clientID.
|
||||
* @param camera_name The Frigate camera name.
|
||||
* @returns A RecordingSummary object.
|
||||
*/
|
||||
export const getRecordingsSummary = async (
|
||||
hass: HomeAssistant,
|
||||
client_id: string,
|
||||
clientID: string,
|
||||
camera_name: string,
|
||||
): Promise<RecordingSummary> => {
|
||||
return await homeAssistantWSRequest(
|
||||
@@ -24,7 +31,7 @@ export const getRecordingsSummary = async (
|
||||
recordingSummarySchema,
|
||||
{
|
||||
type: 'frigate/recordings/summary',
|
||||
instance_id: client_id,
|
||||
instance_id: clientID,
|
||||
camera: camera_name,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
@@ -63,19 +70,19 @@ export const getRecordingSegments = async (
|
||||
/**
|
||||
* Request that Frigate retain an event. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param client_id The Frigate client_id.
|
||||
* @param clientID The Frigate clientID.
|
||||
* @param eventID The event ID to retain.
|
||||
* @param retain `true` to retain or `false` to unretain.
|
||||
*/
|
||||
export async function retainEvent(
|
||||
hass: HomeAssistant,
|
||||
client_id: string,
|
||||
clientID: string,
|
||||
eventID: string,
|
||||
retain: boolean,
|
||||
): Promise<void> {
|
||||
const retainRequest = {
|
||||
type: 'frigate/event/retain',
|
||||
instance_id: client_id,
|
||||
instance_id: clientID,
|
||||
event_id: eventID,
|
||||
retain: retain,
|
||||
};
|
||||
@@ -126,3 +133,19 @@ export const getEvents = async (
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
export const getEventSummary = async (
|
||||
hass: HomeAssistant,
|
||||
clientID: string,
|
||||
): Promise<EventSummary> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
eventSummarySchema,
|
||||
{
|
||||
type: 'frigate/events/summary',
|
||||
instance_id: clientID,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { dayToDate } from '../../utils/basic';
|
||||
|
||||
const dayStringToDate = (arg: unknown): Date | unknown => {
|
||||
return typeof arg === 'string' ? dayToDate(arg) : arg;
|
||||
};
|
||||
|
||||
export const eventSchema = z.object({
|
||||
camera: z.string(),
|
||||
@@ -25,11 +30,7 @@ const recordingSummaryHourSchema = z.object({
|
||||
|
||||
export const recordingSummarySchema = z
|
||||
.object({
|
||||
day: z.preprocess((arg) => {
|
||||
// Must provide the hour:minute:second on parsing or Javascript will
|
||||
// assume *UTC* midnight.
|
||||
return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg;
|
||||
}, z.date()),
|
||||
day: z.preprocess(dayStringToDate, z.date()),
|
||||
events: z.number(),
|
||||
hours: recordingSummaryHourSchema.array(),
|
||||
})
|
||||
@@ -55,3 +56,13 @@ export interface FrigateRecording {
|
||||
endTime: Date;
|
||||
events: number;
|
||||
}
|
||||
|
||||
export const eventSummarySchema = z
|
||||
.object({
|
||||
camera: z.string(),
|
||||
day: z.string(),
|
||||
label: z.string(),
|
||||
zones: z.string().array(),
|
||||
})
|
||||
.array();
|
||||
export type EventSummary = z.infer<typeof eventSummarySchema>;
|
||||
|
||||
+41
-1
@@ -1,11 +1,12 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../types.js';
|
||||
import { arrayify, setify } from '../utils/basic.js';
|
||||
import { allPromises, arrayify, setify } from '../utils/basic.js';
|
||||
import {
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadata,
|
||||
MediaQuery,
|
||||
PartialDataQuery,
|
||||
PartialEventQuery,
|
||||
@@ -115,6 +116,45 @@ export class CameraManager {
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
): Promise<MediaMetadata | null> {
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const engines = this._engineFactory.getAllEngines(this._cameras);
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const processMetadata = async (engine: CameraManagerEngine): Promise<void> => {
|
||||
const engineMetadata = await engine.getMediaMetadata(hass, this._cameras);
|
||||
if (engineMetadata) {
|
||||
if (engineMetadata.what) {
|
||||
engineMetadata.what.forEach(what.add, what);
|
||||
}
|
||||
if (engineMetadata.where) {
|
||||
engineMetadata.where.forEach(where.add, where);
|
||||
}
|
||||
if (engineMetadata.days) {
|
||||
engineMetadata.days.forEach(days.add, days);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await allPromises(engines, (engine) => processMetadata(engine));
|
||||
|
||||
if (!what.size && !where.size && !days.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
}
|
||||
}
|
||||
|
||||
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PQT,
|
||||
|
||||
@@ -68,6 +68,12 @@ export type EventQueryResultsMap = ResultsMap<EventQuery>;
|
||||
export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>;
|
||||
export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>;
|
||||
|
||||
export interface MediaMetadata {
|
||||
where?: Set<string>;
|
||||
what?: Set<string>;
|
||||
days?: Set<string>;
|
||||
}
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
@@ -31,6 +33,11 @@ import './timeline-core.js';
|
||||
import { EventQuery, QueryType } from '../camera/types';
|
||||
import { EventMediaQueries } from '../view/media-queries';
|
||||
import { createViewForEvents } from '../utils/media-to-view.js';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { prettifyTitle } from '../utils/basic';
|
||||
import format from 'date-fns/format';
|
||||
import parse from 'date-fns/parse';
|
||||
import endOfMonth from 'date-fns/endOfMonth';
|
||||
|
||||
@customElement('frigate-card-media-filter')
|
||||
export class FrigateCardMediaFilter extends LitElement {
|
||||
@@ -50,6 +57,7 @@ export class FrigateCardMediaFilter extends LitElement {
|
||||
public mediaLimit?: number;
|
||||
|
||||
protected _cameraOptions: ValueLabel<string>[] = [];
|
||||
protected _mediaMetadataController?: MediaMetadataController;
|
||||
|
||||
protected _convertWhenToDateRange(
|
||||
value?: MediaFilterCoreWhenSelection,
|
||||
@@ -127,35 +135,23 @@ export class FrigateCardMediaFilter extends LitElement {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
|
||||
this._mediaMetadataController = new MediaMetadataController(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
// TODO Replace with real custom when options
|
||||
// TODO Replace with real custom what options
|
||||
// TODO Replace with real custom where options
|
||||
const whereOptions = [{ value: 'steps', label: 'Front Steps' }];
|
||||
|
||||
const whatOptions = [
|
||||
{ value: 'car', label: 'Car' },
|
||||
{ value: 'person', label: 'Person' },
|
||||
];
|
||||
|
||||
const whenOptions = [
|
||||
{
|
||||
value: {
|
||||
selection: MediaFilterCoreWhen.Custom,
|
||||
custom: { start: startOfToday(), end: endOfToday() },
|
||||
},
|
||||
label: 'December 2021',
|
||||
},
|
||||
];
|
||||
|
||||
return html` <frigate-card-media-filter-core
|
||||
.hass=${this.hass}
|
||||
.whenOptions=${whenOptions}
|
||||
.whenOptions=${this._mediaMetadataController?.whenOptions}
|
||||
.cameraOptions=${this._cameraOptions}
|
||||
.whatOptions=${whatOptions}
|
||||
.whereOptions=${whereOptions}
|
||||
.whatOptions=${this._mediaMetadataController?.whatOptions}
|
||||
.whereOptions=${this._mediaMetadataController?.whereOptions}
|
||||
@frigate-card:media-filter-core:change=${this._mediaFilterHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-media-filter-core>`;
|
||||
@@ -170,6 +166,65 @@ export class FrigateCardMediaFilter extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaMetadataController implements ReactiveController {
|
||||
protected _host: ReactiveControllerHost;
|
||||
protected _hass: HomeAssistant;
|
||||
protected _cameraManager: CameraManager;
|
||||
|
||||
public whenOptions: ValueLabel<MediaFilterCoreWhenSelection>[] = [];
|
||||
public whatOptions: ValueLabel<string>[] = [];
|
||||
public whereOptions: ValueLabel<string>[] = [];
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
) {
|
||||
this._host = host;
|
||||
this._hass = hass;
|
||||
this._cameraManager = cameraManager;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
async hostConnected() {
|
||||
const metadata = await this._cameraManager.getMediaMetadata(this._hass);
|
||||
if (metadata) {
|
||||
if (metadata.what) {
|
||||
this.whatOptions = [...metadata.what]
|
||||
.sort()
|
||||
.map((what) => ({ value: what, label: prettifyTitle(what) }));
|
||||
}
|
||||
if (metadata.where) {
|
||||
this.whereOptions = [...metadata.where]
|
||||
.sort()
|
||||
.map((where) => ({ value: where, label: prettifyTitle(where) }));
|
||||
}
|
||||
if (metadata.days) {
|
||||
const yearMonths: Set<string> = new Set();
|
||||
[...metadata.days].forEach((day) => {
|
||||
// An efficient conversion: 2023-01-26 -> 2023-01
|
||||
yearMonths.add(day.substring(0, 7));
|
||||
});
|
||||
const monthStarts: Date[] = [];
|
||||
yearMonths.forEach((yearMonth) => {
|
||||
monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date()));
|
||||
});
|
||||
this.whenOptions = monthStarts
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((monthStart) => ({
|
||||
label: format(monthStart, 'MMMM yyyy'),
|
||||
value: {
|
||||
selection: MediaFilterCoreWhen.Custom,
|
||||
custom: { start: monthStart, end: endOfMonth(monthStart) },
|
||||
},
|
||||
}));
|
||||
}
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-media-filter': FrigateCardMediaFilter;
|
||||
|
||||
+18
-1
@@ -32,6 +32,7 @@ export function dispatchFrigateCardEvent<T>(
|
||||
* @param input The input Frigate (camera/label/zone) name.
|
||||
* @returns A prettified name.
|
||||
*/
|
||||
export function prettifyTitle(input: string): string;
|
||||
export function prettifyTitle(input?: string): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
@@ -161,5 +162,21 @@ export function getDurationString(start: Date, end: Date): string {
|
||||
* @param seconds
|
||||
*/
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise(r => setTimeout(r, seconds * 1000));
|
||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||
};
|
||||
|
||||
export const allPromises = async <T>(
|
||||
items: T[],
|
||||
func: (arg: T) => void,
|
||||
): Promise<void> => {
|
||||
await Promise.all(Array.from(items).map((item) => func(item)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple efficient YYYY-MM-DD -> date converter.
|
||||
*/
|
||||
export const dayToDate = (day: string): Date => {
|
||||
// Must provide the hour:minute:second on parsing or Javascript will assume
|
||||
// *UTC* midnight.
|
||||
return new Date(`${day}T00:00:00`);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript
|
||||
// - TODO: getRecordingTitle should use getCameraTitle but need hass.
|
||||
// - TODO: Take MediaQueries wrappers out of the camera manager.
|
||||
// - TODO: View a media in the gallery from September, then notice timeline missing the item.
|
||||
|
||||
// Gallery:
|
||||
// - TODO: Event gallery show_details default does not work.
|
||||
|
||||
Reference in New Issue
Block a user