diff --git a/src/camera/frigate/engine-frigate.ts b/src/camera/frigate/engine-frigate.ts index 04552145..c27d3297 100644 --- a/src/camera/frigate/engine-frigate.ts +++ b/src/camera/frigate/engine-frigate.ts @@ -6,8 +6,7 @@ import { CAMERA_BIRDSEYE } from '../../const'; import { CameraConfig, RecordingSegment } from '../../types'; import { MediaQueriesResults } from '../../view/media-queries-results'; import { MediaQueriesClassifier } from '../../view/media-queries-classifier'; -import { ViewMedia, ViewMediaFactory } from '../../view/media'; -import { ViewMediaClassifier } from '../../view/media-classifier'; +import { ViewMedia } from '../../view/media'; import { RecordingSegmentsCache } from '../cache'; import { CameraManagerEngine, @@ -45,6 +44,9 @@ import throttle from 'lodash-es/throttle'; import { runWhenIdleIfSupported } from '../../utils/basic'; import { fromUnixTime } from 'date-fns'; import { sum } from 'lodash-es'; +import { FrigateViewMediaClassifier } from './media-classifier'; +import { ViewMediaClassifier } from '../../view/media-classifier'; +import { FrigateViewMediaFactory } from './media'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -93,13 +95,13 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { media: ViewMedia, ): string | null { let path: string | null = null; - if (ViewMediaClassifier.isFrigateEvent(media)) { + if (FrigateViewMediaClassifier.isFrigateEvent(media)) { path = `/api/frigate/${cameraConfig.frigate.client_id}` + `/notifications/${media.getID()}/` + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + `?download=true`; - } else if (ViewMediaClassifier.isFrigateRecording(media)) { + } else if (FrigateViewMediaClassifier.isFrigateRecording(media)) { path = `/api/frigate/${cameraConfig.frigate.client_id}` + `/recording/${cameraConfig.frigate.camera_name}` + @@ -160,7 +162,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { favorite: boolean, ): Promise { const clientID = cameraConfig.frigate.client_id; - if (!ViewMediaClassifier.isFrigateEvent(media)) { + if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { return; } @@ -331,7 +333,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { if (!mediaType) { continue; } - const media = ViewMediaFactory.createViewMediaFromFrigateEvent( + const media = FrigateViewMediaFactory.createEventViewMedia( mediaType, query.cameraID, event, @@ -353,7 +355,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { const output: ViewMedia[] = []; for (const recording of results.recordings) { - const media = ViewMediaFactory.createViewMediaFromFrigateRecording( + const media = FrigateViewMediaFactory.createRecordingViewMedia( query.cameraID, recording, ); diff --git a/src/camera/frigate/media-classifier.ts b/src/camera/frigate/media-classifier.ts new file mode 100644 index 00000000..442d6b0d --- /dev/null +++ b/src/camera/frigate/media-classifier.ts @@ -0,0 +1,18 @@ +import { ViewMedia } from '../../view/media'; +import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; + +export class FrigateViewMediaClassifier { + public static isFrigateMedia( + media: ViewMedia, + ): media is FrigateEventViewMedia | FrigateRecordingViewMedia { + return this.isFrigateEvent(media) || this.isFrigateRecording(media); + } + public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia { + return media instanceof FrigateEventViewMedia; + } + public static isFrigateRecording( + media: ViewMedia, + ): media is FrigateRecordingViewMedia { + return media instanceof FrigateRecordingViewMedia; + } +} diff --git a/src/camera/frigate/media.ts b/src/camera/frigate/media.ts new file mode 100644 index 00000000..b58b8c07 --- /dev/null +++ b/src/camera/frigate/media.ts @@ -0,0 +1,164 @@ +import fromUnixTime from 'date-fns/fromUnixTime'; +import isEqual from 'lodash-es/isEqual'; +import { CameraConfig } from '../../types'; +import { ViewMediaBase, EventViewMedia, RecordingViewMedia } from '../../view/media'; +import { FrigateEvent, FrigateRecording } from './types'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingMediaContentID, + getRecordingTitle, +} from './util'; + +export class FrigateEventViewMedia + extends ViewMediaBase + implements EventViewMedia +{ + public hasClip(): boolean { + return !!this._source.has_clip; + } + public getStartTime(): Date { + return fromUnixTime(this._source.start_time); + } + public getEndTime(): Date | null { + return this._source.end_time ? fromUnixTime(this._source.end_time) : null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getID(_cameraConfig?: CameraConfig): string { + return this._source.id; + } + public getContentID(cameraConfig?: CameraConfig): string | null { + if ( + !cameraConfig || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name + ) { + return null; + } + return getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + this._source, + this._mediaType === 'clip' ? 'clips' : 'snapshots', + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getTitle(_cameraConfig?: CameraConfig): string | null { + return getEventTitle(this._source); + } + + public getThumbnail(cameraConfig?: CameraConfig): string | null { + if (cameraConfig?.frigate.client_id) { + return getEventThumbnailURL(cameraConfig.frigate.client_id, this._source); + } + return null; + } + public isFavorite(): boolean | null { + return this._source.retain_indefinitely ?? null; + } + public setFavorite(favorite: boolean): void { + this._source.retain_indefinitely = favorite; + } + public getWhat(): string[] | null { + return [this._source.label]; + } + public getWhere(): string[] | null { + const zones = this._source.zones; + return zones.length ? zones : null; + } + public getScore(): number | null { + return this._source.top_score; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } + + public getClipEquivalent(): EventViewMedia | null { + if (!this.hasClip()) { + return null; + } + return FrigateViewMediaFactory.createEventViewMedia( + 'clip', + this._cameraID, + this._source, + ); + } +} + +export class FrigateRecordingViewMedia + extends ViewMediaBase + implements RecordingViewMedia +{ + public getID(cameraConfig?: CameraConfig): string | null { + // ID name is derived from the real camera name (not CameraID) since the + // recordings for the same camera across multiple zones will be the same and + // can be dedup'd from this id. + if (cameraConfig) { + return `${cameraConfig.frigate?.client_id ?? ''}/${ + cameraConfig.frigate.camera_name ?? '' + }/${this._source.startTime.getTime()}/${this._source.endTime.getTime()}}`; + } + return null; + } + public getStartTime(): Date { + return this._source.startTime; + } + public getEndTime(): Date { + return this._source.endTime; + } + public getContentID(cameraConfig?: CameraConfig): string | null { + if ( + !cameraConfig || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name + ) { + return null; + } + return getRecordingMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + this._source, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getTitle(cameraConfig?: CameraConfig): string | null { + if (!cameraConfig) { + return null; + } + return getRecordingTitle(cameraConfig, this._source); + } + public getEventCount(): number { + return this._source.events; + } +} + +export class FrigateViewMediaFactory { + static createEventViewMedia( + type: 'clip' | 'snapshot', + cameraID: string, + event: FrigateEvent, + ): FrigateEventViewMedia | null { + if ( + (type === 'clip' && event.has_clip) || + (type === 'snapshot' && event.has_snapshot) + ) { + return new FrigateEventViewMedia(type, cameraID, event); + } + return null; + } + + static createRecordingViewMedia( + cameraID: string, + recording: FrigateRecording, + ): FrigateRecordingViewMedia | null { + return new FrigateRecordingViewMedia('recording', cameraID, recording); + } +} diff --git a/src/camera/frigate/util.ts b/src/camera/frigate/util.ts index 42a8becd..112ba4ff 100644 --- a/src/camera/frigate/util.ts +++ b/src/camera/frigate/util.ts @@ -1,6 +1,5 @@ import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; -import fromUnixTime from 'date-fns/fromUnixTime'; -import { ClipsOrSnapshots } from '../../types'; +import { CameraConfig, ClipsOrSnapshots } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; import { FrigateEvent, FrigateRecording } from './types'; @@ -22,9 +21,12 @@ export const getEventTitle = (event: FrigateEvent): string => { )}%]`; }; -export const getRecordingTitle = (recording: FrigateRecording): string => { - return `${prettifyTitle(recording.camera)} ${formatDateAndTime( - fromUnixTime(recording.start_time), +export const getRecordingTitle = ( + cameraConfig: CameraConfig, + recording: FrigateRecording, +): string => { + return `${prettifyTitle(cameraConfig.frigate.camera_name)} ${formatDateAndTime( + recording.startTime, )}`; }; @@ -67,15 +69,16 @@ export const getRecordingMediaContentID = ( cameraName: string, recording: FrigateRecording, ): string => { - const date = fromUnixTime(recording.start_time); return [ 'media-source://frigate', clientId, 'recordings', cameraName, - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( - String(date.getDate()).padStart(2, '0'), + `${recording.startTime.getFullYear()}-${String( + recording.startTime.getMonth() + 1, + ).padStart(2, '0')}-${String( + String(recording.startTime.getDate()).padStart(2, '0'), )}`, - String(date.getHours()).padStart(2, '0'), + String(recording.startTime.getHours()).padStart(2, '0'), ].join('/'); }; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index f7b3aeee..531b6c82 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -128,7 +128,7 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) - public media?: EventViewMedia; + public media?: EventViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; @@ -180,7 +180,7 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) - public media?: RecordingViewMedia; + public media?: RecordingViewMedia; @property({ attribute: false }) public mediaSeek?: MediaSeek; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index e8fc69f6..1d697e4b 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -374,8 +374,11 @@ export class FrigateCardViewerCarousel extends LitElement { !media || // If this specific media item has no clip, then do nothing (even if all // the other media items do). - !ViewMediaClassifier.isFrigateEvent(media) || - !media.hasClip() || + !ViewMediaClassifier.isEvent(media) || + // If the event certainly has no clip, don't bother going further. If + // we're not sure for this camera type (i.e. hasClip() === null) the query + // will proceed anyway. + media.hasClip() === false || !MediaQueriesClassifier.areEventQueries(this.view.query) ) { return; diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index e824e353..f744c870 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -179,7 +179,8 @@ export const mergeFrigateBrowseMediaSources = async ( return createEventParentForChildren( 'Merged events', - children.sort(sortYoungestToOldest), + children + // TODO: Restore sort or remove: children.sort(sortYoungestToOldest), ); }; @@ -350,25 +351,26 @@ export const createChild = ( return result; }; -/** - * Sort the timeline items most recent to least recent. - * @param a The first item. - * @param b The second item. - * @returns -1, 0, 1 (standard array sort function configuration). - */ -export const sortYoungestToOldest = ( - a: FrigateBrowseMediaSource, - b: FrigateBrowseMediaSource, -): number => { - const a_source = a.frigate?.event ?? a.frigate?.recording; - const b_source = b.frigate?.event ?? b.frigate?.recording; +// TODO Remove or fix this if not needed post gallery. +// /** +// * Sort the timeline items most recent to least recent. +// * @param a The first item. +// * @param b The second item. +// * @returns -1, 0, 1 (standard array sort function configuration). +// */ +// export const sortYoungestToOldest = ( +// a: FrigateBrowseMediaSource, +// b: FrigateBrowseMediaSource, +// ): number => { +// const a_source = a.frigate?.event ?? a.frigate?.recording; +// const b_source = b.frigate?.event ?? b.frigate?.recording; - if (!a_source || (b_source && b_source.start_time > a_source.start_time)) { - return 1; - } +// if (!a_source || (b_source && b_source.start_time > a_source.start_time)) { +// return 1; +// } - if (!b_source || (a_source && b_source.start_time < a_source.start_time)) { - return -1; - } - return 0; -}; +// if (!b_source || (a_source && b_source.start_time < a_source.start_time)) { +// return -1; +// } +// return 0; +// }; diff --git a/src/view/media-classifier.ts b/src/view/media-classifier.ts index 6f91af21..ab71290d 100644 --- a/src/view/media-classifier.ts +++ b/src/view/media-classifier.ts @@ -1,29 +1,10 @@ -import { - ViewMedia, - FrigateEventViewMedia, - FrigateRecordingViewMedia, - RecordingViewMedia, - EventViewMedia, -} from './media'; +import { ViewMedia, RecordingViewMedia, EventViewMedia } from './media'; export class ViewMediaClassifier { - public static isFrigateMedia( - media: ViewMedia, - ): media is FrigateEventViewMedia | FrigateRecordingViewMedia { - return this.isFrigateEvent(media) || this.isFrigateRecording(media); - } - public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia { - return media instanceof FrigateEventViewMedia; - } - public static isFrigateRecording( - media: ViewMedia, - ): media is FrigateRecordingViewMedia { - return media instanceof FrigateRecordingViewMedia; - } - public static isEvent(media: ViewMedia): media is EventViewMedia { + public static isEvent(media: ViewMedia): media is EventViewMedia { return this.isClip(media) || this.isSnapshot(media); } - public static isRecording(media: ViewMedia): media is RecordingViewMedia { + public static isRecording(media: ViewMedia): media is RecordingViewMedia { return media.getMediaType() === 'recording'; } public static isClip(media: ViewMedia): boolean { @@ -33,6 +14,6 @@ export class ViewMediaClassifier { return media.getMediaType() === 'snapshot'; } public static isVideo(media: ViewMedia): boolean { - return this.isClip(media) || this.isRecording(media); + return this.isClip(media) || this.isRecording(media); } } diff --git a/src/view/media.ts b/src/view/media.ts index 17119fb2..6b87f65d 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -1,19 +1,7 @@ -import fromUnixTime from 'date-fns/fromUnixTime'; -import isEqual from 'lodash-es/isEqual'; import { CameraConfig } from '../types.js'; -import { - getEventMediaContentID, - getEventThumbnailURL, - getEventTitle, - getRecordingMediaContentID, - getRecordingTitle, -} from '../camera/frigate/util.js'; -import { FrigateEvent, FrigateRecording } from '../camera/frigate/types.js'; - export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; -export type ViewMediaSourceType = FrigateEvent | FrigateRecording; -class ViewMediaBase { +export class ViewMediaBase { protected _mediaType: ViewMediaType; protected _cameraID: string; protected _source: T; @@ -72,167 +60,15 @@ class ViewMediaBase { } } -export interface EventViewMedia extends ViewMedia { +export interface EventViewMedia extends ViewMediaBase { getScore(): number | null; getWhat(): string[] | null; - isGroupableWith(that: EventViewMedia): boolean; + isGroupableWith(that: EventViewMedia): boolean; + hasClip(): boolean | null; } -export interface RecordingViewMedia extends ViewMedia { +export interface RecordingViewMedia extends ViewMediaBase { getEventCount(): number | null; } -// Creates a 'public interface only' version of ViewMediaBase for use elsewhere -// (typescript struggles with the ViewMediaClassifier classification functions -// used above if the object has data elements). -export type ViewMedia = { - [P in keyof ViewMediaBase]: ViewMediaBase[P]; -}; - -export class FrigateEventViewMedia - extends ViewMediaBase - implements EventViewMedia -{ - public hasClip(): boolean { - return !!this._source.has_clip; - } - public getClipEquivalent(): ViewMedia | null { - if (!this.hasClip()) { - return null; - } - return ViewMediaFactory.createViewMediaFromFrigateEvent( - 'clip', - this._cameraID, - this._source, - ); - } - public getStartTime(): Date { - return fromUnixTime(this._source.start_time); - } - public getEndTime(): Date | null { - return this._source.end_time ? fromUnixTime(this._source.end_time) : null; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getID(_cameraConfig?: CameraConfig): string { - return this._source.id; - } - public getContentID(cameraConfig?: CameraConfig): string | null { - if ( - !cameraConfig || - !cameraConfig.frigate.client_id || - !cameraConfig.frigate.camera_name - ) { - return null; - } - return getEventMediaContentID( - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - this._source, - this._mediaType === 'clip' ? 'clips' : 'snapshots', - ); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getTitle(_cameraConfig?: CameraConfig): string | null { - return getEventTitle(this._source); - } - - public getThumbnail(cameraConfig?: CameraConfig): string | null { - if (cameraConfig?.frigate.client_id) { - return getEventThumbnailURL(cameraConfig.frigate.client_id, this._source); - } - return null; - } - public isFavorite(): boolean | null { - return this._source.retain_indefinitely ?? null; - } - public setFavorite(favorite: boolean): void { - this._source.retain_indefinitely = favorite; - } - public getWhat(): string[] | null { - return [this._source.label]; - } - public getWhere(): string[] | null { - const zones = this._source.zones; - return zones.length ? zones : null; - } - public getScore(): number | null { - return this._source.top_score; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isGroupableWith(that: EventViewMedia): boolean { - return ( - this.getMediaType() === that.getMediaType() && - isEqual(this.getWhere(), that.getWhere()) && - isEqual(this.getWhat(), that.getWhat()) - ); - } -} - -export class FrigateRecordingViewMedia - extends ViewMediaBase - implements RecordingViewMedia -{ - public getID(cameraConfig?: CameraConfig): string | null { - // ID name is derived from the real camera name (not CameraID) since the - // recordings for the same camera across multiple zones will be the same and - // can be dedup'd from this id. - if (cameraConfig) { - return `${cameraConfig.frigate?.client_id ?? ''}/${ - cameraConfig.frigate.camera_name ?? '' - }/${this._source.start_time}/${this._source.end_time}}`; - } - return null; - } - public getStartTime(): Date { - return fromUnixTime(this._source.start_time); - } - public getEndTime(): Date { - return fromUnixTime(this._source.end_time); - } - public getContentID(cameraConfig?: CameraConfig): string | null { - if ( - !cameraConfig || - !cameraConfig.frigate.client_id || - !cameraConfig.frigate.camera_name - ) { - return null; - } - return getRecordingMediaContentID( - cameraConfig.frigate.client_id, - cameraConfig.frigate.camera_name, - this._source, - ); - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public getTitle(_cameraConfig?: CameraConfig): string | null { - return getRecordingTitle(this._source); - } - public getEventCount(): number { - return this._source.events; - } -} - -export class ViewMediaFactory { - static createViewMediaFromFrigateEvent( - type: 'clip' | 'snapshot', - cameraID: string, - event: FrigateEvent, - ): ViewMedia | null { - if ( - (type === 'clip' && event.has_clip) || - (type === 'snapshot' && event.has_snapshot) - ) { - return new FrigateEventViewMedia(type, cameraID, event); - } - return null; - } - - static createViewMediaFromFrigateRecording( - cameraID: string, - recording: FrigateRecording, - ): ViewMedia | null { - return new FrigateRecordingViewMedia('recording', cameraID, recording); - } -} +export type ViewMedia = ViewMediaBase;