From 1ea961265b7e484737363f2e4ef2057ac458c282 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 5 Mar 2023 09:52:55 -0800 Subject: [PATCH] Add sub_label support. --- src/camera-manager/frigate/engine-frigate.ts | 17 +++++++++ src/camera-manager/frigate/media.ts | 12 +++++++ src/camera-manager/frigate/types.ts | 2 ++ src/camera-manager/manager.ts | 5 +++ src/camera-manager/types.ts | 6 +++- src/components/media-filter.ts | 38 ++++++++++++++++++-- src/components/thumbnail.ts | 10 +++++- src/localize/languages/en.json | 3 ++ src/view/media.ts | 1 + 9 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 2ecf063b..c8e1cc48 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -448,6 +448,7 @@ export class FrigateCameraManagerEngine cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), ...(query.what && { labels: Array.from(query.what) }), ...(query.where && { zones: Array.from(query.where) }), + ...(query.tags && { sub_labels: Array.from(query.tags) }), ...(query.end && { before: Math.floor(query.end.getTime() / 1000) }), ...(query.start && { after: Math.floor(query.start.getTime() / 1000) }), ...(query.limit && { limit: query.limit }), @@ -704,6 +705,7 @@ export class FrigateCameraManagerEngine cameraID, cameraConfig, event, + event.sub_label ? this._splitSubLabels(event.sub_label) : undefined, ); if (media) { output.push(media); @@ -795,6 +797,16 @@ export class FrigateCameraManagerEngine return cameraConfig; } + protected _splitSubLabels(input: string): string[] { + // A note on Frigate sub_labels: As of Frigate v0.12 sub_labels is a string + // (not an array) per event, but may contain comma-separated values (e.g. + // double-take (https://github.com/jakowenko/double-take) identifying two + // people in the same photo). When we search for multiple sub_labels, the + // integration will comma-join them together, then the Frigate backend will + // do the magic to match exactly or against a comma-separated part. + return input.split(',').map((s) => s.trim()); + } + public async getMediaMetadata( hass: HomeAssistant, cameras: CameraConfigs, @@ -814,6 +826,7 @@ export class FrigateCameraManagerEngine const what: Set = new Set(); const where: Set = new Set(); const days: Set = new Set(); + const tags: Set = new Set(); const instances = this._buildInstanceToCameraIDMapFromQuery( cameras, @@ -840,6 +853,9 @@ export class FrigateCameraManagerEngine if (entry.day) { days.add(entry.day); } + if (entry.sub_label) { + this._splitSubLabels(entry.sub_label).forEach(tags.add, tags); + } } }; @@ -880,6 +896,7 @@ export class FrigateCameraManagerEngine ...(what.size && { what: what }), ...(where.size && { where: where }), ...(days.size && { days: days }), + ...(tags.size && { tags: tags }), }, expiry: add(new Date(), { seconds: MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS }), cached: false, diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index aad8acbe..e98b4ee3 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -21,6 +21,7 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { protected _event: FrigateEvent; protected _contentID: string; protected _thumbnail: string; + protected _subLabels: string[] | null; constructor( mediaType: ViewMediaType, @@ -28,11 +29,17 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { event: FrigateEvent, contentID: string, thumbnail: string, + // See 'A note on Frigate sub_labels' in engine-frigate.ts for more + // details about why sub-labels are treated specially. By taking in + // subLabels as an array here, we can keep a single place that splits + // sublabels (`_splitSubLabels` in engine-frigate.ts). + subLabels?: string[], ) { super(mediaType, cameraID); this._event = event; this._contentID = contentID; this._thumbnail = thumbnail; + this._subLabels = subLabels ?? null; } public hasClip(): boolean { @@ -72,6 +79,9 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { public getScore(): number | null { return this._event.top_score; } + public getTags(): string[] | null { + return this._subLabels; + } // eslint-disable-next-line @typescript-eslint/no-unused-vars public isGroupableWith(that: EventViewMedia): boolean { @@ -130,6 +140,7 @@ export class FrigateViewMediaFactory { cameraID: string, cameraConfig: CameraConfig, event: FrigateEvent, + subLabels?: string[], ): FrigateEventViewMedia | null { if ( (mediaType === 'clip' && !event.has_clip) || @@ -151,6 +162,7 @@ export class FrigateViewMediaFactory { mediaType === 'clip' ? 'clips' : 'snapshots', ), getEventThumbnailURL(cameraConfig.frigate.client_id, event), + subLabels, ); } diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index 55440667..a80481eb 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -19,6 +19,7 @@ const eventSchema = z.object({ has_snapshot: z.boolean(), id: z.string(), label: z.string(), + sub_label: z.string().nullable(), start_time: z.number(), top_score: z.number(), zones: z.string().array(), @@ -68,6 +69,7 @@ export const eventSummarySchema = z camera: z.string(), day: z.string(), label: z.string(), + sub_label: z.string().nullable(), zones: z.string().array(), }) .array(); diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index ec9da565..bfed71cd 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -279,6 +279,7 @@ export class CameraManager { } public async getMediaMetadata(hass: HomeAssistant): Promise { + const tags: Set = new Set(); const what: Set = new Set(); const where: Set = new Set(); const days: Set = new Set(); @@ -291,6 +292,9 @@ export class CameraManager { const results = await this._handleQuery(hass, query); for (const result of results?.values() ?? []) { + if (result.metadata.tags) { + result.metadata.tags.forEach(tags.add, tags); + } if (result.metadata.what) { result.metadata.what.forEach(what.add, what); } @@ -306,6 +310,7 @@ export class CameraManager { return null; } return { + ...(tags.size && { tags: tags }), ...(what.size && { what: what }), ...(where.size && { where: where }), ...(days.size && { days: days }), diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 56c643e7..0d0bfd89 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -84,9 +84,10 @@ export type RecordingSegmentsQueryResultsMap = ResultsMap; export interface MediaMetadata { + days?: Set; + tags?: Set; where?: Set; what?: Set; - days?: Set; } interface BaseCapabilities { @@ -145,6 +146,9 @@ export interface EventQuery extends MediaQuery { // Frigate equivalent: label what?: Set; + // Frigate equivalent: sub_label + tags?: Set; + // Frigate equivalent: zone where?: Set; } diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index 255390e0..44e5d6b3 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -41,12 +41,13 @@ import orderBy from 'lodash-es/orderBy'; import { CardWideConfig } from '../types'; interface MediaFilterCoreDefaults { - mediaType?: MediaFilterMediaType; cameraIDs?: string[]; - what?: string[]; - where?: string[]; favorite?: MediaFilterCoreFavoriteSelection; + mediaType?: MediaFilterMediaType; + what?: string[]; when?: string; + where?: string[]; + tags?: string[]; } export enum MediaFilterCoreFavoriteSelection { @@ -100,6 +101,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { protected _refWhat: Ref = createRef(); protected _refWhere: Ref = createRef(); protected _refFavorite: Ref = createRef(); + protected _refTags: Ref = createRef(); constructor() { super(); @@ -206,11 +208,13 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { ) { const where = getArrayValueAsSet(this._refWhere.value?.value); const what = getArrayValueAsSet(this._refWhat.value?.value); + const tags = getArrayValueAsSet(this._refTags.value?.value); const queries = new EventMediaQueries([ { type: QueryType.Event, cameraIDs: cameraIDs, + ...(tags && { tags: tags }), ...(what && { what: what }), ...(where && { where: where }), ...(favorite !== null && { favorite: favorite }), @@ -328,6 +332,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { let what: string[] | undefined; let where: string[] | undefined; let favorite: MediaFilterCoreFavoriteSelection | undefined; + let tags: string[] | undefined; const cameraIDSets = uniqWith( queries.map((query: DataQuery) => query.cameraIDs), @@ -385,6 +390,13 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { if (whereSets.length === 1 && queries[0].where?.size) { where = [...queries[0].where]; } + const tagsSets = uniqWith( + queries.map((query) => query.tags), + isEqual, + ); + if (tagsSets.length === 1 && queries[0].tags?.size) { + tags = [...queries[0].tags]; + } } else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) { mediaType = MediaFilterMediaType.Recordings; } @@ -395,6 +407,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { ...(what && { what: what }), ...(where && { where: where }), ...(favorite !== undefined && { favorite: favorite }), + ...(tags && { tags: tags }) }; } @@ -461,6 +474,19 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { > ` : ''} + ${areEvents && this._mediaMetadataController.tagsOptions.length + ? html` + ` + : ''} ${areEvents && this._mediaMetadataController.whereOptions.length ? html` ({ value: where, label: prettifyTitle(where) })); } + if (metadata.tags) { + this.tagsOptions = [...metadata.tags] + .sort() + .map((tag) => ({ value: tag, label: prettifyTitle(tag) })); + } if (metadata.days) { const yearMonths: Set = new Set(); [...metadata.days].forEach((day) => { diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 8ac0c52a..fbb4f636 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -167,11 +167,13 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { const endTime = this.media.getEndTime(); const what = this.media.getWhat(); const where = this.media.getWhere(); + const tags = this.media.getTags(); return html` ${what ? html`
- ${prettifyTitle(what.join(', '))} + ${prettifyTitle(what.join(', ')) + + (tags ? ': ' + prettifyTitle(tags.join(', ')) : '')} ${score ? html`(${(score * 100).toFixed(2) + '%'})` : ''}
` : ``} @@ -200,6 +202,12 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { ${prettifyTitle(where.join(', '))} ` : html``} + ${tags + ? html`
+ + ${prettifyTitle(tags.join(', '))} +
` + : html``} ${this.seek ? html`
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 85a9e6d9..45e470ec 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -398,6 +398,7 @@ "score": "Score", "seek": "Seek", "start": "Start", + "tag": "Tag", "what": "What", "where": "Where" }, @@ -418,6 +419,8 @@ "select_what": "Select what...", "select_when": "Select when...", "select_where": "Select where...", + "select_tag": "Select tag...", + "tag": "Tag", "what": "What", "when": "When", "whens": { diff --git a/src/view/media.ts b/src/view/media.ts index a1f198e7..ee9bd832 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -58,6 +58,7 @@ export class ViewMedia { export interface EventViewMedia extends ViewMedia { getScore(): number | null; getWhat(): string[] | null; + getTags(): string[] | null; isGroupableWith(that: EventViewMedia): boolean; hasClip(): boolean | null; }