Initial motionEye commit.
This commit is contained in:
+2
-2
@@ -43,7 +43,7 @@
|
||||
"vis-util": "^5.0.2",
|
||||
"web-dialog": "^0.0.11",
|
||||
"xss": "^1.0.14",
|
||||
"zod": "^3.20.6"
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.0",
|
||||
@@ -51,7 +51,7 @@
|
||||
"@babel/plugin-proposal-decorators": "^7.19.0",
|
||||
"@rollup/plugin-babel": "^5.3.1",
|
||||
"@rollup/plugin-commonjs": "^22.0.2",
|
||||
"@rollup/plugin-image": "^2.1.1",
|
||||
"@rollup/plugin-image": "^3.0.2",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import {
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
PartialEventQuery,
|
||||
CameraConfigs,
|
||||
CameraManagerCameraCapabilities,
|
||||
QueryType,
|
||||
CameraEndpoint,
|
||||
} from '../types';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { BrowseMediaMetadata } from './types';
|
||||
import { rangesOverlap } from '../range';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
|
||||
import { canonicalizeHAURL } from '../../utils/ha';
|
||||
import { RequestCache } from '../cache';
|
||||
|
||||
/**
|
||||
* A base class for cameras that read events from HA BrowseMedia interface.
|
||||
*/
|
||||
export class BrowseMediaCameraManagerEngine
|
||||
extends GenericCameraManagerEngine
|
||||
implements CameraManagerEngine
|
||||
{
|
||||
protected _cameraEntities: Map<string, Entity> = new Map();
|
||||
protected _browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>;
|
||||
protected _resolvedMediaCache: ResolvedMediaCache;
|
||||
protected _requestCache: RequestCache;
|
||||
|
||||
public constructor(
|
||||
browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>,
|
||||
resolvedMediaCache: ResolvedMediaCache,
|
||||
requestCache: RequestCache,
|
||||
) {
|
||||
super();
|
||||
this._browseMediaManager = browseMediaManager;
|
||||
this._resolvedMediaCache = resolvedMediaCache;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
|
||||
public async initializeCamera(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<CameraConfig> {
|
||||
const entity = cameraConfig.camera_entity
|
||||
? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity)
|
||||
: null;
|
||||
if (!entity || !cameraConfig.camera_entity) {
|
||||
throw new CameraInitializationError(
|
||||
localize('error.no_camera_entity'),
|
||||
cameraConfig,
|
||||
);
|
||||
}
|
||||
this._cameraEntities.set(cameraConfig.camera_entity, entity);
|
||||
return cameraConfig;
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
_cameras: CameraConfigs,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialEventQuery,
|
||||
): EventQuery[] | null {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility method to determine if a browse media object matches against a
|
||||
* start and end date.
|
||||
* @param media The browse media object (with rich metadata).
|
||||
* @param start The optional start date.
|
||||
* @param end The optional end date.
|
||||
* @returns `true` if the media falls within the provided dates.
|
||||
*/
|
||||
protected _mediaIsWithinDates = (
|
||||
media: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
start?: Date,
|
||||
end?: Date,
|
||||
): boolean => {
|
||||
// If no date is specified at all, everything matches.
|
||||
const dateReference = start ?? end;
|
||||
if (!dateReference) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If there's no metadata, nothing matches.
|
||||
if (!media._metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine if:
|
||||
// - The media starts within the query timeframe.
|
||||
// - The media ends within the query timeframe.
|
||||
// - The media entirely encompasses the query timeframe.
|
||||
return rangesOverlap(
|
||||
{
|
||||
start: media._metadata.startDate,
|
||||
end: media._metadata.endDate,
|
||||
},
|
||||
{
|
||||
start: start ?? dateReference,
|
||||
end: end ?? dateReference,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
public async getMediaDownloadPath(
|
||||
hass: ExtendedHomeAssistant,
|
||||
_cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): Promise<CameraEndpoint | null> {
|
||||
const contentID = media.getContentID();
|
||||
if (!contentID) {
|
||||
return null;
|
||||
}
|
||||
const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache);
|
||||
return resolvedMedia
|
||||
? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) }
|
||||
: null;
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(query: DataQuery): number | null {
|
||||
if (query.type === QueryType.Event) {
|
||||
return BROWSE_MEDIA_CACHE_SECONDS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public getCameraCapabilities(
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraCapabilities | null {
|
||||
const parentCapabilities = super.getCameraCapabilities(cameraConfig);
|
||||
if (!parentCapabilities) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...parentCapabilities,
|
||||
supportsClips: true,
|
||||
supportsSnapshots: true,
|
||||
supportsTimeline: true,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
|
||||
return {
|
||||
canFavorite: false,
|
||||
canDownload: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { formatDateAndTime } from '../../utils/basic';
|
||||
import { MEDIA_CLASS_VIDEO, RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||
import {
|
||||
ViewMedia,
|
||||
EventViewMedia,
|
||||
ViewMediaType,
|
||||
VideoContentType,
|
||||
} from '../../view/media';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
|
||||
class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
protected _browseMedia: RichBrowseMedia<BrowseMediaMetadata>;
|
||||
|
||||
constructor(
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._browseMedia = browseMedia;
|
||||
}
|
||||
|
||||
public hasClip(): boolean {
|
||||
return this._browseMedia.media_class === MEDIA_CLASS_VIDEO;
|
||||
}
|
||||
public getStartTime(): Date | null {
|
||||
return this._browseMedia._metadata?.startDate ?? null;
|
||||
}
|
||||
public getEndTime(): Date | null {
|
||||
return null;
|
||||
}
|
||||
public getVideoContentType(): VideoContentType | null {
|
||||
return VideoContentType.MP4;
|
||||
}
|
||||
public getID(): string {
|
||||
return this.getContentID();
|
||||
}
|
||||
public getContentID(): string {
|
||||
return this._browseMedia.media_content_id;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
const startTime = this.getStartTime();
|
||||
return startTime ? formatDateAndTime(startTime) : this._browseMedia.title;
|
||||
}
|
||||
public getThumbnail(): string | null {
|
||||
return this._browseMedia.thumbnail;
|
||||
}
|
||||
public getWhat(): string[] | null {
|
||||
return null;
|
||||
}
|
||||
public getScore(): number | null {
|
||||
return null;
|
||||
}
|
||||
public getTags(): string[] | null {
|
||||
return null;
|
||||
}
|
||||
public isGroupableWith(that: EventViewMedia): boolean {
|
||||
return (
|
||||
this.getMediaType() === that.getMediaType() &&
|
||||
isEqual(this.getWhere(), that.getWhere()) &&
|
||||
isEqual(this.getWhat(), that.getWhat())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowseMediaViewMediaFactory {
|
||||
static createEventViewMedia(
|
||||
mediaType: 'clip' | 'snapshot',
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
cameraID: string,
|
||||
): BrowseMediaEventViewMedia | null {
|
||||
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface BrowseMediaMetadata {
|
||||
cameraID: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ interface CameraManagerCache<Request, Response> {
|
||||
set(request: Request, response: Response, expiry?: Date): void;
|
||||
}
|
||||
|
||||
class MemoryRequestCache<Request, Response>
|
||||
export class MemoryRequestCache<Request, Response>
|
||||
implements CameraManagerCache<Request, Response>
|
||||
{
|
||||
protected _data: RequestCacheItem<Request, Response>[] = [];
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { localize } from '../localize/localize';
|
||||
import { CameraConfig, CardWideConfig } from '../types';
|
||||
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
|
||||
import { BrowseMedia } from '../utils/ha/browse-media/types';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { Entity } from '../utils/ha/entity-registry/types';
|
||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraInitializationError } from './error';
|
||||
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
|
||||
import { GenericCameraManagerEngine } from './generic/engine-generic';
|
||||
import { MotionEyeCameraManagerEngine } from './motioneye/engine-motioneye';
|
||||
import { Engine } from './types';
|
||||
|
||||
export class CameraManagerEngineFactory {
|
||||
protected _entityRegistryManager: EntityRegistryManager;
|
||||
protected _resolvedMediaCache: ResolvedMediaCache;
|
||||
protected _cardWideConfig: CardWideConfig;
|
||||
|
||||
constructor(
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
resolvedMediaCache: ResolvedMediaCache,
|
||||
cardWideConfig: CardWideConfig,
|
||||
) {
|
||||
this._entityRegistryManager = entityRegistryManager;
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
this._resolvedMediaCache = resolvedMediaCache;
|
||||
}
|
||||
|
||||
public createEngine(engine: Engine): CameraManagerEngine | null {
|
||||
@@ -35,6 +42,12 @@ export class CameraManagerEngineFactory {
|
||||
new RequestCache(),
|
||||
);
|
||||
break;
|
||||
case Engine.MotionEye:
|
||||
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
||||
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
|
||||
this._resolvedMediaCache,
|
||||
new RequestCache(),
|
||||
);
|
||||
}
|
||||
return cameraManagerEngine;
|
||||
}
|
||||
@@ -50,6 +63,8 @@ export class CameraManagerEngineFactory {
|
||||
let engine: Engine | null = null;
|
||||
if (cameraConfig.engine === 'frigate') {
|
||||
engine = Engine.Frigate;
|
||||
} else if (cameraConfig.engine === 'motioneye') {
|
||||
engine = Engine.MotionEye;
|
||||
} else if (cameraConfig.engine === 'auto') {
|
||||
const cameraEntity = cameraConfig.camera_entity;
|
||||
|
||||
@@ -70,6 +85,9 @@ export class CameraManagerEngineFactory {
|
||||
case 'frigate':
|
||||
engine = Engine.Frigate;
|
||||
break;
|
||||
case 'motioneye':
|
||||
engine = Engine.MotionEye;
|
||||
break;
|
||||
default:
|
||||
engine = Engine.Generic;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../types';
|
||||
import { CameraConfig, ExtendedHomeAssistant } from '../types';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResultsMap,
|
||||
EngineOptions,
|
||||
CameraEndpoint,
|
||||
} from './types';
|
||||
|
||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
@@ -90,7 +91,11 @@ export interface CameraManagerEngine {
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
|
||||
getMediaDownloadPath(
|
||||
hass: ExtendedHomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): Promise<CameraEndpoint | null>;
|
||||
|
||||
favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.4999 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 936 B |
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import { CameraConfig, CardWideConfig } from '../../types';
|
||||
import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { RecordingSegmentsCache, RequestCache } from '../cache';
|
||||
import {
|
||||
@@ -80,6 +80,7 @@ import { localize } from '../../localize/localize';
|
||||
import uniq from 'lodash-es/uniq';
|
||||
import format from 'date-fns/format';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import frigateLogo from './assets/frigate-logo-dark.svg';
|
||||
|
||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
@@ -302,26 +303,32 @@ export class FrigateCameraManagerEngine
|
||||
return null;
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(
|
||||
public async getMediaDownloadPath(
|
||||
_hass: ExtendedHomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): string | null {
|
||||
let path: string | null = null;
|
||||
): Promise<CameraEndpoint | null> {
|
||||
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/notifications/${media.getID()}/` +
|
||||
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||
`?download=true`;
|
||||
return {
|
||||
endpoint:
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/notifications/${media.getID()}/` +
|
||||
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||
`?download=true`,
|
||||
sign: true,
|
||||
};
|
||||
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||
`?download=true`;
|
||||
return {
|
||||
endpoint:
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||
`?download=true`,
|
||||
sign: true,
|
||||
};
|
||||
}
|
||||
return path;
|
||||
return null;
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
@@ -1097,6 +1104,7 @@ export class FrigateCameraManagerEngine
|
||||
cameraConfig.id ??
|
||||
'',
|
||||
icon: metadata.icon,
|
||||
engineLogo: frigateLogo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1178,7 +1186,7 @@ export class FrigateCameraManagerEngine
|
||||
};
|
||||
|
||||
const getWebRTCCard = (): CameraEndpoint | null => {
|
||||
// By defaykt use the frigate camera name which is the default recommended
|
||||
// By default use the frigate camera name which is the default recommended
|
||||
// setup as per:
|
||||
// https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/
|
||||
//
|
||||
@@ -1194,14 +1202,11 @@ export class FrigateCameraManagerEngine
|
||||
const jsmpeg = getJSMPEG();
|
||||
const webrtcCard = getWebRTCCard();
|
||||
|
||||
return ui || go2rtc || jsmpeg
|
||||
? {
|
||||
...(ui && { ui: ui }),
|
||||
...(go2rtc && { go2rtc: go2rtc }),
|
||||
...(jsmpeg && { jsmpeg: jsmpeg }),
|
||||
...(jsmpeg && { jsmpeg: jsmpeg }),
|
||||
...(webrtcCard && { webrtcCard: webrtcCard }),
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
...(ui && { ui: ui }),
|
||||
...(go2rtc && { go2rtc: go2rtc }),
|
||||
...(jsmpeg && { jsmpeg: jsmpeg }),
|
||||
...(webrtcCard && { webrtcCard: webrtcCard }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EventViewMedia,
|
||||
RecordingViewMedia,
|
||||
ViewMediaType,
|
||||
VideoContentType,
|
||||
} from '../../view/media';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
import {
|
||||
@@ -51,6 +52,14 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
public getEndTime(): Date | null {
|
||||
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
|
||||
}
|
||||
public inProgress(): boolean | null {
|
||||
// In Frigate, events/recordings always have end times unless they are in
|
||||
// progress.
|
||||
return !this.getEndTime();
|
||||
}
|
||||
public getVideoContentType(): VideoContentType | null {
|
||||
return VideoContentType.HLS;
|
||||
}
|
||||
public getID(): string {
|
||||
return this._event.id;
|
||||
}
|
||||
@@ -123,6 +132,11 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
||||
public getEndTime(): Date {
|
||||
return this._recording.endTime;
|
||||
}
|
||||
public inProgress(): boolean | null {
|
||||
// In Frigate, events/recordings always have end times unless they are in
|
||||
// progress.
|
||||
return !this.getEndTime();
|
||||
}
|
||||
public getContentID(): string | null {
|
||||
return this._contentID;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface FrigateRecording {
|
||||
export const eventSummarySchema = z
|
||||
.object({
|
||||
camera: z.string(),
|
||||
// Days in RFC3339 format.
|
||||
day: z.string(),
|
||||
label: z.string(),
|
||||
sub_label: z.string().nullable(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../../types';
|
||||
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import {
|
||||
CameraManagerCameraMetadata,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResultsMap,
|
||||
EngineOptions,
|
||||
CameraEndpoint,
|
||||
} from '../types';
|
||||
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
@@ -112,10 +113,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(
|
||||
public async getMediaDownloadPath(
|
||||
_hass: ExtendedHomeAssistant,
|
||||
_cameraConfig: CameraConfig,
|
||||
_media: ViewMedia,
|
||||
): string | null {
|
||||
): Promise<CameraEndpoint | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -174,12 +176,12 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
): CameraManagerCameraCapabilities | null {
|
||||
return {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings:false,
|
||||
canFavoriteRecordings: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig, CamerasConfig, CardWideConfig } from '../types.js';
|
||||
import {
|
||||
CameraConfig,
|
||||
CamerasConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
} from '../types.js';
|
||||
import { allPromises, arrayify, setify } from '../utils/basic.js';
|
||||
import {
|
||||
CameraManagerCameraCapabilities,
|
||||
@@ -34,11 +39,10 @@ import {
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResults,
|
||||
EngineOptions,
|
||||
CameraEndpoint,
|
||||
} from './types.js';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
import { ViewMedia } from '../view/media.js';
|
||||
import uniqBy from 'lodash-es/uniqBy';
|
||||
import { CameraManagerEngine } from './engine.js';
|
||||
import sum from 'lodash-es/sum';
|
||||
import add from 'date-fns/add';
|
||||
@@ -50,6 +54,7 @@ import { CameraInitializationError } from './error.js';
|
||||
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||
import { sortMedia } from './util.js';
|
||||
|
||||
class QueryClassifier {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
@@ -356,7 +361,7 @@ export class CameraManager {
|
||||
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
|
||||
}
|
||||
}
|
||||
return concreteQueries;
|
||||
return concreteQueries.length ? concreteQueries : null;
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
@@ -459,20 +464,31 @@ export class CameraManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputMedia = sortMedia(results.concat(newChunkMedia));
|
||||
|
||||
// If the media did not _ACTUALLY_ get longer, there is no new media despite
|
||||
// the increased limit, so just return null.
|
||||
if (outputMedia.length === results.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
queries: extendedQueries,
|
||||
results: this._sortMedia(results.concat(newChunkMedia)),
|
||||
results: outputMedia,
|
||||
};
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(media: ViewMedia): string | null {
|
||||
public async getMediaDownloadPath(
|
||||
hass: ExtendedHomeAssistant,
|
||||
media: ViewMedia,
|
||||
): Promise<CameraEndpoint | null> {
|
||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||
const engine = this._store.getEngineForMedia(media);
|
||||
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getMediaDownloadPath(cameraConfig, media);
|
||||
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
|
||||
}
|
||||
|
||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
|
||||
@@ -623,7 +639,7 @@ export class CameraManager {
|
||||
await Promise.all(_queries.map((query) => processQuery(query)));
|
||||
|
||||
const cachedOutputQueries = sum(
|
||||
Array.from(results.values()).map((result) => Number(result.cached)),
|
||||
Array.from(results.values()).map((result) => Number(result.cached ?? 0)),
|
||||
);
|
||||
|
||||
log(
|
||||
@@ -681,20 +697,7 @@ export class CameraManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._sortMedia(mediaArray);
|
||||
}
|
||||
|
||||
protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] {
|
||||
return orderBy(
|
||||
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
||||
// are assumed to be unique.
|
||||
uniqBy(mediaArray, (media) => media.getID() ?? media),
|
||||
|
||||
// Sort all items leading oldest -> youngest (so media is loaded in this
|
||||
// order in the viewer which matches the left-to-right timeline order).
|
||||
(media) => media.getStartTime(),
|
||||
'asc',
|
||||
);
|
||||
return sortMedia(mediaArray);
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="0.91 r13725"
|
||||
width="64"
|
||||
height="64"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="motioneye-icon.svg"
|
||||
inkscape:export-filename="/home/ccrisan/projects/motioneye/static/img/motioneye-logo.png"
|
||||
inkscape:export-xdpi="960"
|
||||
inkscape:export-ydpi="960"><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6"><linearGradient
|
||||
id="linearGradient4351"
|
||||
inkscape:collect="always"><stop
|
||||
id="stop4353"
|
||||
offset="0"
|
||||
style="stop-color:#737373;stop-opacity:1" /><stop
|
||||
id="stop4355"
|
||||
offset="1"
|
||||
style="stop-color:#585858;stop-opacity:1" /></linearGradient><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient4205"><stop
|
||||
style="stop-color:#4aa3e0;stop-opacity:1"
|
||||
offset="0"
|
||||
id="stop4207" /><stop
|
||||
style="stop-color:#3096db;stop-opacity:1"
|
||||
offset="1"
|
||||
id="stop4209" /></linearGradient><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient4197"><stop
|
||||
style="stop-color:#787878;stop-opacity:1"
|
||||
offset="0"
|
||||
id="stop4199" /><stop
|
||||
style="stop-color:#585858;stop-opacity:1"
|
||||
offset="1"
|
||||
id="stop4201" /></linearGradient><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4351"
|
||||
id="linearGradient4203"
|
||||
x1="26.445793"
|
||||
y1="47.517574"
|
||||
x2="26.445793"
|
||||
y2="3.8183768"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4205"
|
||||
id="linearGradient4211"
|
||||
x1="26.602072"
|
||||
y1="43.034946"
|
||||
x2="26.602072"
|
||||
y2="29.466328"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)" /><filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter4285"><feFlood
|
||||
flood-opacity="0.588235"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood4287" /><feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite4289" /><feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0.6"
|
||||
result="blur"
|
||||
id="feGaussianBlur4291" /><feOffset
|
||||
dx="0"
|
||||
dy="-1"
|
||||
result="offset"
|
||||
id="feOffset4293" /><feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="composite2"
|
||||
id="feComposite4295" /></filter><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4197"
|
||||
id="linearGradient4309"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="26.445793"
|
||||
y1="47.517574"
|
||||
x2="26.445793"
|
||||
y2="3.8183768" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4197"
|
||||
id="linearGradient4311"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="26.445793"
|
||||
y1="47.517574"
|
||||
x2="26.445793"
|
||||
y2="3.8183768" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4197"
|
||||
id="linearGradient4313"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="26.445793"
|
||||
y1="47.517574"
|
||||
x2="26.445793"
|
||||
y2="3.8183768" /><filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter4315"
|
||||
x="-0.10000000000000001"
|
||||
y="-0.16000000000000003"><feFlood
|
||||
flood-opacity="0.588235"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood4317" /><feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite4319" /><feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0.6"
|
||||
result="blur"
|
||||
id="feGaussianBlur4321" /><feOffset
|
||||
dx="0"
|
||||
dy="-1"
|
||||
result="offset"
|
||||
id="feOffset4323" /><feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="composite2"
|
||||
id="feComposite4325" /></filter><filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter4327"><feFlood
|
||||
flood-opacity="0.588235"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood4329" /><feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite4331" /><feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0.6"
|
||||
result="blur"
|
||||
id="feGaussianBlur4333" /><feOffset
|
||||
dx="0"
|
||||
dy="-1"
|
||||
result="offset"
|
||||
id="feOffset4335" /><feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="composite2"
|
||||
id="feComposite4337" /></filter><filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter4339"><feFlood
|
||||
flood-opacity="0.588235"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood4341" /><feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite4343" /><feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0.2"
|
||||
result="blur"
|
||||
id="feGaussianBlur4345" /><feOffset
|
||||
dx="0"
|
||||
dy="-0.5"
|
||||
result="offset"
|
||||
id="feOffset4347" /><feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="composite2"
|
||||
id="feComposite4349" /></filter></defs><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1025"
|
||||
id="namedview4"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2"
|
||||
inkscape:cx="-94.597631"
|
||||
inkscape:cy="10.226517"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g10"
|
||||
showguides="true"
|
||||
inkscape:guide-bbox="true" /><g
|
||||
id="g10"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="ink_ext_XXXXXX"
|
||||
transform="matrix(1.25,0,0,-1.25,0,64)"><g
|
||||
id="g4170"
|
||||
style="fill:url(#linearGradient4203);fill-opacity:1;filter:url(#filter4327)"
|
||||
transform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)"><path
|
||||
id="path4244"
|
||||
d="M 8.9346154,40.515385 C 5.3647588,36.547307 3.2,31.357779 3.2,25.6 3.2,13.228821 13.228821,3.2 25.6,3.2 37.971179,3.2 48,13.228821 48,25.6 c 0,5.736682 -2.161128,10.952493 -5.707692,14.915385 -1.695935,-0.623286 -3.387833,-1.349065 -5.061539,-2.288462 3.2394,-0.937363 5.6,-3.937988 5.6,-7.457692 0,-4.260339 -3.469626,-7.753846 -7.753846,-7.753846 -3.633936,0 -6.690552,2.51055 -7.538461,5.869231 l -3.876924,0 c -0.840685,-3.360193 -3.903443,-5.869231 -7.538461,-5.869231 -4.284219,0 -7.7807693,3.493507 -7.7807693,7.753846 0,3.56112 2.4570323,6.5856 5.7615383,7.484616 -1.676267,0.912203 -3.404813,1.620556 -5.1692306,2.261538 z M 25.6,26.461538 c 0.532632,-1.981435 1.101793,-3.947553 3.446154,-5.16923 L 25.6,16.123077 22.153846,21.292308 c 2.053593,1.454966 3.000771,3.237758 3.446154,5.16923 z"
|
||||
style="fill:url(#linearGradient4309);fill-opacity:1;stroke:none"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
id="path4242"
|
||||
d="m 16.123077,33.353847 c -1.427443,0 -2.584616,-1.157173 -2.584616,-2.584616 0,-1.427444 1.157173,-2.584615 2.584616,-2.584615 1.427444,0 2.584615,1.157171 2.584615,2.584615 0,1.427443 -1.157171,2.584616 -2.584615,2.584616 z"
|
||||
style="fill:url(#linearGradient4311);fill-opacity:1;stroke:none"
|
||||
inkscape:connector-curvature="0" /><path
|
||||
id="path4240"
|
||||
d="m 35.076923,33.353847 c -1.427443,0 -2.584615,-1.157173 -2.584615,-2.584616 0,-1.427444 1.157172,-2.584615 2.584615,-2.584615 1.427443,0 2.584616,1.157171 2.584616,2.584615 0,1.427443 -1.157173,2.584616 -2.584616,2.584616 z"
|
||||
style="fill:url(#linearGradient4313);fill-opacity:1;stroke:none"
|
||||
inkscape:connector-curvature="0" /></g><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#737373;fill-opacity:1;stroke:none;filter:url(#filter4339)"
|
||||
d="m 25.6,47.2 c -4.373944,0 -8.437159,-1.399808 -11.838461,-3.634616 3.677605,-0.394237 7.305921,-1.342945 11.423077,-3.375 4.166157,2.122533 8.434154,3.008875 12.279808,3.452886 C 34.057131,45.890032 29.986674,47.2 25.6,47.2 Z"
|
||||
id="path4248" /><path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:url(#linearGradient4211);fill-opacity:1;stroke:none;filter:url(#filter4315)"
|
||||
d="M 39.723077,42.552884 C 35.394064,42.5242 29.479588,40.397223 25.184616,38.432418 20.668821,40.064102 16.035448,42.649343 10.801923,42.526924 10.453022,42.51873 10.118061,42.50105 9.7634616,42.475 L 5.6615384,42.1375 9.5557693,40.839424 c 5.3417977,-1.74056 10.0398397,-2.851302 14.1749997,-10.025963 0.959101,0 2.845924,-4.15e-4 3.738462,-4.15e-4 4.11884,7.134039 9.059296,8.324614 14.149039,10.026378 L 45.460577,42.085577 41.4625,42.475 c -0.544847,0.05181 -1.120992,0.08198 -1.739423,0.07788 z"
|
||||
id="path4246"
|
||||
sodipodi:nodetypes="cccccccccccc" /></g></svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,427 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../../types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import {
|
||||
CameraConfigs,
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResults,
|
||||
MediaMetadataQueryResultsMap,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
} from '../types';
|
||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
import {
|
||||
BrowseMediaStep,
|
||||
BrowseMediaTarget,
|
||||
} from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMedia,
|
||||
MEDIA_CLASS_IMAGE,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import parse from 'date-fns/parse';
|
||||
import { MotionEyeEventQueryResults } from './types';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import add from 'date-fns/add';
|
||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { BrowseMediaViewMediaFactory } from '../browse-media/media';
|
||||
import motioneyeLogo from './assets/motioneye-logo.svg';
|
||||
|
||||
class MotionEyeQueryResultsClassifier {
|
||||
public static isMotionEyeEventQueryResults(
|
||||
results: QueryResults,
|
||||
): results is MotionEyeEventQueryResults {
|
||||
return (
|
||||
results.engine === Engine.MotionEye && results.type === QueryResultsType.Event
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
|
||||
'%Y': 'yyyy',
|
||||
'%m': 'MM',
|
||||
'%d': 'dd',
|
||||
'%H': 'HH',
|
||||
'%M': 'mm',
|
||||
'%S': 'SS',
|
||||
};
|
||||
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
|
||||
|
||||
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
public getEngineType(): Engine {
|
||||
return Engine.MotionEye;
|
||||
}
|
||||
|
||||
protected _convertMotionEyeTimeFormatToDateFNS(part: string): string {
|
||||
return part.replace(
|
||||
MOTIONEYE_REPL_REGEXP,
|
||||
(_, key) => MOTIONEYE_REPL_SUBSTITUTIONS[key],
|
||||
);
|
||||
}
|
||||
|
||||
// Get metadata for a MotionEye media file.
|
||||
protected _motionEyeMetadataGeneratorFile(
|
||||
cameraID: string,
|
||||
dateFormat: string | null,
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
): BrowseMediaMetadata | null {
|
||||
let startDate = parent?._metadata?.startDate ?? new Date();
|
||||
if (dateFormat) {
|
||||
const extensionlessTitle = media.title.replace(/\.[^/.]+$/, '');
|
||||
startDate = parse(extensionlessTitle, dateFormat, startDate);
|
||||
if (!isValidDate(startDate)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
cameraID: cameraID,
|
||||
startDate: startDate,
|
||||
// MotionEye only has start times, the event is effectively a 'point'
|
||||
endDate: startDate,
|
||||
};
|
||||
}
|
||||
|
||||
// Get metadata for a MotionEye media directory.
|
||||
protected _motionEyeMetadataGeneratorDirectory(
|
||||
cameraID: string,
|
||||
dateFormat: string | null,
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
): BrowseMediaMetadata | null {
|
||||
let startDate = parent?._metadata?.startDate ?? new Date();
|
||||
if (dateFormat) {
|
||||
const parsedDate = parse(media.title, dateFormat, startDate);
|
||||
if (!isValidDate(parsedDate)) {
|
||||
return null;
|
||||
}
|
||||
startDate = startOfDay(parsedDate);
|
||||
}
|
||||
return {
|
||||
cameraID: cameraID,
|
||||
startDate: startDate,
|
||||
endDate: parent?._metadata?.endDate ?? endOfDay(startDate),
|
||||
};
|
||||
}
|
||||
|
||||
// Get media directories that match a given criteria.
|
||||
protected async _getMatchingDirectories(
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
cameraID: string,
|
||||
matchOptions?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
hasClip?: boolean;
|
||||
hasSnapshot?: boolean;
|
||||
} | null,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
|
||||
const cameraEntityID = cameras.get(cameraID)?.camera_entity;
|
||||
const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null;
|
||||
const configID = entity?.config_entry_id;
|
||||
const deviceID = entity?.device_id;
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
|
||||
if (!configID || !deviceID || !cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generateNextStep = (
|
||||
parts: string[],
|
||||
media: BrowseMediaTarget<BrowseMediaMetadata>[],
|
||||
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
||||
const next = parts.shift();
|
||||
if (!next) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateFormat = next.includes('%')
|
||||
? this._convertMotionEyeTimeFormatToDateFNS(next)
|
||||
: null;
|
||||
|
||||
return [
|
||||
{
|
||||
targets: media,
|
||||
metadataGenerator: (
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
) =>
|
||||
this._motionEyeMetadataGeneratorDirectory(
|
||||
cameraID,
|
||||
dateFormat,
|
||||
media,
|
||||
parent,
|
||||
),
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
media.can_expand &&
|
||||
(!!dateFormat || media.title === next) &&
|
||||
this._mediaIsWithinDates(media, matchOptions?.start, matchOptions?.end),
|
||||
advance: (media) => generateNextStep(parts, media),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// For motionEye snapshots and clips are mutually exclusive.
|
||||
return await this._browseMediaManager.walkBrowseMedias(
|
||||
hass,
|
||||
[
|
||||
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
|
||||
? generateNextStep(
|
||||
cameraConfig.motioneye.movies.directory_pattern.split('/'),
|
||||
[`media-source://motioneye/${configID}#${deviceID}#movies`],
|
||||
)
|
||||
: []),
|
||||
...(matchOptions?.hasSnapshot !== false && !matchOptions?.hasClip
|
||||
? generateNextStep(
|
||||
cameraConfig.motioneye.images.directory_pattern.split('/'),
|
||||
[`media-source://motioneye/${configID}#${deviceID}#images`],
|
||||
)
|
||||
: []),
|
||||
],
|
||||
{
|
||||
useCache: engineOptions?.useCache,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: EventQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
// MotionEye does not support these query types and they will never match.
|
||||
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: EventQueryResultsMap = new Map();
|
||||
const getEventsForCamera = async (cameraID: string): Promise<void> => {
|
||||
const perCameraQuery = { ...query, cameraIDs: new Set([cameraID]) };
|
||||
const cachedResult =
|
||||
engineOptions?.useCache ?? true ? this._requestCache.get(perCameraQuery) : null;
|
||||
if (cachedResult) {
|
||||
output.set(perCameraQuery, cachedResult as EventQueryResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directories = await this._getMatchingDirectories(
|
||||
hass,
|
||||
cameras,
|
||||
cameraID,
|
||||
perCameraQuery,
|
||||
engineOptions,
|
||||
);
|
||||
if (!directories || !directories.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moviesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
|
||||
cameraConfig.motioneye.movies.file_pattern,
|
||||
);
|
||||
const imagesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
|
||||
cameraConfig.motioneye.images.file_pattern,
|
||||
);
|
||||
|
||||
const media = await this._browseMediaManager.walkBrowseMedias(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
targets: directories,
|
||||
metadataGenerator: (
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
) => {
|
||||
if (
|
||||
media.media_class === MEDIA_CLASS_IMAGE ||
|
||||
media.media_class === MEDIA_CLASS_VIDEO
|
||||
) {
|
||||
return this._motionEyeMetadataGeneratorFile(
|
||||
cameraID,
|
||||
media.media_class === MEDIA_CLASS_IMAGE
|
||||
? imagesDateFormat
|
||||
: moviesDateFormat,
|
||||
media,
|
||||
parent,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
!media.can_expand &&
|
||||
this._mediaIsWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||
},
|
||||
],
|
||||
{ useCache: engineOptions?.useCache },
|
||||
);
|
||||
|
||||
// Sort by most recent then slice at the query limit.
|
||||
const sortedMedia = orderBy(
|
||||
media,
|
||||
(media: RichBrowseMedia<BrowseMediaMetadata>) => media._metadata?.startDate,
|
||||
'desc',
|
||||
).slice(0, perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT);
|
||||
|
||||
const result: MotionEyeEventQueryResults = {
|
||||
type: QueryResultsType.Event,
|
||||
engine: Engine.MotionEye,
|
||||
browseMedia: sortedMedia,
|
||||
};
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(
|
||||
perCameraQuery,
|
||||
{ ...result, cached: true },
|
||||
result.expiry,
|
||||
);
|
||||
}
|
||||
output.set(perCameraQuery, result);
|
||||
};
|
||||
|
||||
await allPromises(query.cameraIDs, (cameraID) => getEventsForCamera(cameraID));
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public generateMediaFromEvents(
|
||||
_hass: HomeAssistant,
|
||||
_cameras: CameraConfigs,
|
||||
_query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!MotionEyeQueryResultsClassifier.isMotionEyeEventQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const browseMedia of results.browseMedia) {
|
||||
const cameraID = browseMedia._metadata?.cameraID;
|
||||
if (!cameraID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaType =
|
||||
browseMedia.media_class === MEDIA_CLASS_VIDEO
|
||||
? 'clip'
|
||||
: browseMedia.media_class === MEDIA_CLASS_IMAGE
|
||||
? 'snapshot'
|
||||
: null;
|
||||
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
}
|
||||
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
|
||||
mediaType,
|
||||
browseMedia,
|
||||
cameraID,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: CameraConfigs,
|
||||
query: MediaMetadataQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<MediaMetadataQueryResultsMap | null> {
|
||||
const output: MediaMetadataQueryResultsMap = new Map();
|
||||
if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) {
|
||||
const cachedResult = <MediaMetadataQueryResults | null>(
|
||||
this._requestCache.get(query)
|
||||
);
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as MediaMetadataQueryResults);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
const days: Set<string> = new Set();
|
||||
const getDaysForCamera = async (cameraID: string): Promise<void> => {
|
||||
const directories = await this._getMatchingDirectories(
|
||||
hass,
|
||||
cameras,
|
||||
cameraID,
|
||||
null,
|
||||
engineOptions,
|
||||
);
|
||||
for (const dayDirectory of directories ?? []) {
|
||||
if (dayDirectory._metadata) {
|
||||
days.add(formatDate(dayDirectory._metadata?.startDate));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID));
|
||||
|
||||
const result: MediaMetadataQueryResults = {
|
||||
type: QueryResultsType.MediaMetadata,
|
||||
engine: Engine.MotionEye,
|
||||
metadata: {
|
||||
...(days.size && { days: days }),
|
||||
},
|
||||
expiry: add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||
cached: false,
|
||||
};
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
}
|
||||
output.set(query, result);
|
||||
return output;
|
||||
}
|
||||
|
||||
public getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata {
|
||||
const metadata = super.getCameraMetadata(hass, cameraConfig);
|
||||
return {
|
||||
...metadata,
|
||||
engineLogo: motioneyeLogo,
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
cameraConfig: CameraConfig,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
||||
return cameraConfig.motioneye?.url
|
||||
? {
|
||||
endpoint: cameraConfig.motioneye.url,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const ui = getUIEndpoint();
|
||||
return {
|
||||
...(ui && { ui: ui }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Converted from https://raw.githubusercontent.com/motioneye-project/motioneye/python2/motioneye/static/img/motioneye-icon.svg .
|
||||
export const MOTIONEYE_ICON_SVG_VIEWBOX = '0 0 64 64';
|
||||
export const MOTIONEYE_ICON_SVG_PATH =
|
||||
'M 49.65,10.81 ' +
|
||||
'C 44.24,10.84 36.85,13.50 31.48,15.96 ' +
|
||||
'25.84,13.92 20.04,10.69 13.50,10.84 ' +
|
||||
'13.07,10.85 12.65,10.87 12.20,10.91 ' +
|
||||
'12.20,10.91 7.08,11.33 7.08,11.33 ' +
|
||||
'7.08,11.33 11.94,12.95 11.94,12.95 ' +
|
||||
'18.62,15.13 24.49,16.51 29.66,25.48 ' +
|
||||
'30.86,25.48 33.22,25.48 34.34,25.48 ' +
|
||||
'39.49,16.57 45.66,15.08 52.02,12.95 ' +
|
||||
'52.02,12.95 56.83,11.39 56.83,11.39 ' +
|
||||
'56.83,11.39 51.83,10.91 51.83,10.91 ' +
|
||||
'51.15,10.84 50.43,10.80 49.65,10.81 ' +
|
||||
'49.65,10.81 49.65,10.81 49.65,10.81 Z ' +
|
||||
'M 32.00,5.00 ' +
|
||||
'C 26.53,5.00 21.45,6.75 17.20,9.54 ' +
|
||||
'21.80,10.04 26.33,11.22 31.48,13.76 ' +
|
||||
'36.69,11.11 42.02,10.00 46.83,9.45 ' +
|
||||
'42.57,6.64 37.48,5.00 32.00,5.00 Z ' +
|
||||
'M 43.42,22.65 ' +
|
||||
'C 41.70,22.65 40.31,24.05 40.31,25.77 ' +
|
||||
'40.31,27.49 41.70,28.88 43.42,28.88 ' +
|
||||
'45.14,28.88 46.54,27.49 46.54,25.77 ' +
|
||||
'46.54,24.05 45.14,22.65 43.42,22.65 Z ' +
|
||||
'M 20.58,22.65 ' +
|
||||
'C 18.86,22.65 17.46,24.05 17.46,25.77 ' +
|
||||
'17.46,27.49 18.86,28.88 20.58,28.88 ' +
|
||||
'22.30,28.88 23.69,27.49 23.69,25.77 ' +
|
||||
'23.69,24.05 22.30,22.65 20.58,22.65 Z ' +
|
||||
'M 11.91,14.02 ' +
|
||||
'C 7.61,18.80 5.00,25.06 5.00,32.00 ' +
|
||||
'5.00,46.91 17.09,59.00 32.00,59.00 ' +
|
||||
'46.91,59.00 59.00,46.91 59.00,32.00 ' +
|
||||
'59.00,25.09 56.40,18.80 52.12,14.02 ' +
|
||||
'50.08,14.77 48.04,15.65 46.02,16.78 ' +
|
||||
'49.92,17.91 52.77,21.53 52.77,25.77 ' +
|
||||
'52.77,30.90 48.59,35.12 43.42,35.12 ' +
|
||||
'39.04,35.12 35.36,32.09 34.34,28.04 ' +
|
||||
'34.34,28.04 29.66,28.04 29.66,28.04 ' +
|
||||
'28.65,32.09 24.96,35.12 20.58,35.12 ' +
|
||||
'15.41,35.12 11.20,30.90 11.20,25.77 ' +
|
||||
'11.20,21.48 14.16,17.83 18.14,16.75 ' +
|
||||
'16.12,15.65 14.04,14.79 11.91,14.02 ' +
|
||||
'11.91,14.02 11.91,14.02 11.91,14.02 Z ' +
|
||||
'M 32.00,30.96 ' +
|
||||
'C 32.64,33.35 33.33,35.72 36.15,37.19 ' +
|
||||
'36.15,37.19 32.00,43.42 32.00,43.42 ' +
|
||||
'32.00,43.42 27.85,37.19 27.85,37.19 ' +
|
||||
'30.32,35.44 31.46,33.29 32.00,30.96 Z';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { Engine, EventQueryResults } from '../types';
|
||||
|
||||
// ================================
|
||||
// MotionEye concrete query results
|
||||
// ================================
|
||||
|
||||
export interface MotionEyeEventQueryResults extends EventQueryResults {
|
||||
engine: Engine.MotionEye;
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[];
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
|
||||
return (
|
||||
// a starts within the range of b.
|
||||
(a.start >= b.start && a.start <= b.end) ||
|
||||
// a events within the range of b.
|
||||
// a ends within the range of b.
|
||||
(a.end >= b.start && a.end <= b.end) ||
|
||||
// a encompasses the entire range of b.
|
||||
(a.start <= b.start && a.end >= b.end)
|
||||
|
||||
@@ -22,6 +22,7 @@ export enum QueryResultsType {
|
||||
export enum Engine {
|
||||
Frigate = 'frigate',
|
||||
Generic = 'generic',
|
||||
MotionEye = 'motioneye',
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
@@ -110,6 +111,7 @@ export interface CameraManagerMediaCapabilities {
|
||||
export interface CameraManagerCameraMetadata {
|
||||
title: string;
|
||||
icon: string;
|
||||
engineLogo?: string;
|
||||
}
|
||||
|
||||
export interface CameraEndpointsContext {
|
||||
|
||||
@@ -4,6 +4,9 @@ import startOfDay from 'date-fns/startOfDay';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfMinute from 'date-fns/endOfMinute';
|
||||
import { DateRange } from './range';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import uniqBy from 'lodash-es/uniqBy';
|
||||
import { ViewMedia } from '../view/media';
|
||||
|
||||
export const convertRangeToCacheFriendlyTimes = (
|
||||
range: DateRange,
|
||||
@@ -37,3 +40,16 @@ export const capEndDate = (end: Date): Date => {
|
||||
const now = new Date();
|
||||
return end > now ? now : end;
|
||||
};
|
||||
|
||||
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
|
||||
return orderBy(
|
||||
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
||||
// are assumed to be unique.
|
||||
uniqBy(mediaArray, (media) => media.getID() ?? media),
|
||||
|
||||
// Sort all items leading oldest -> youngest (so media is loaded in this
|
||||
// order in the viewer which matches the left-to-right timeline order).
|
||||
(media) => media.getStartTime(),
|
||||
'asc',
|
||||
);
|
||||
};
|
||||
|
||||
+5
-1
@@ -1091,7 +1091,11 @@ class FrigateCard extends LitElement {
|
||||
cardWideConfig: CardWideConfig,
|
||||
): Promise<void> {
|
||||
this._cameraManager = new CameraManager(
|
||||
new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig),
|
||||
new CameraManagerEngineFactory(
|
||||
this._entityRegistryManager,
|
||||
this._resolvedMediaCache,
|
||||
cardWideConfig,
|
||||
),
|
||||
this._cardWideConfig,
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from '../utils/media-to-view.js';
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { dispatchMessageEvent, renderProgressIndicator } from './message.js';
|
||||
import { renderMessage, renderProgressIndicator } from './message.js';
|
||||
import './thumbnail.js';
|
||||
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
@@ -432,13 +432,19 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
}
|
||||
|
||||
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
|
||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
||||
// Note that this is not throwing up an error message for the card to
|
||||
// handle (as typical), but rather directly rendering the message into the
|
||||
// gallery. This is to allow the filter to still be available when a given
|
||||
// filter selection returns no media.
|
||||
return renderMessage({
|
||||
type: 'info',
|
||||
message: localize('common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
});
|
||||
}
|
||||
|
||||
const selected = this.view?.queryResults?.getSelectedResult();
|
||||
return html`
|
||||
return html` <div class="grid">
|
||||
${this._showLoaderTop
|
||||
? html`${renderProgressIndicator({
|
||||
cardWideConfig: this.cardWideConfig,
|
||||
@@ -490,7 +496,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
componentRef: this._refLoaderBottom,
|
||||
})}`
|
||||
: ''}
|
||||
`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
public updated(changedProps: PropertyValues): void {
|
||||
|
||||
@@ -539,8 +539,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
<frigate-card-live-provider
|
||||
?disabled=${this.liveConfig.lazy_load}
|
||||
.cameraConfig=${cameraConfig}
|
||||
.cameraEndpoints=${guard([this.cameraManager, cameraID], () =>
|
||||
this.cameraManager?.getCameraEndpoints(cameraID),
|
||||
.cameraEndpoints=${guard(
|
||||
[this.cameraManager, cameraID],
|
||||
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
|
||||
)}
|
||||
.label=${cameraMetadata?.title ?? ''}
|
||||
.liveConfig=${config}
|
||||
@@ -638,6 +639,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.label="${cameraMetadataCurrent
|
||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||
: ''}"
|
||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||
.titlePopupConfig=${config.controls.title}
|
||||
.selected=${this._getSelectedCameraIndex()}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@@ -723,7 +725,7 @@ export class FrigateCardLiveProvider
|
||||
protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||
|
||||
public async play(): Promise<void> {
|
||||
playMediaMutingIfNecessary(this._refProvider.value);
|
||||
playMediaMutingIfNecessary(this, this._refProvider.value);
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
|
||||
@@ -115,6 +115,9 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public label?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public logo?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public titlePopupConfig?: TitleControlConfig;
|
||||
|
||||
@@ -420,6 +423,7 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
${ref(this._titleControlRef)}
|
||||
.config=${this.titlePopupConfig}
|
||||
.text="${this.label}"
|
||||
.logo="${this.logo}"
|
||||
.fitInto=${this as HTMLElement}
|
||||
>
|
||||
</frigate-card-title-control> `
|
||||
|
||||
@@ -247,7 +247,7 @@ export class FrigateCardMenu extends LitElement {
|
||||
</frigate-card-submenu-select>`;
|
||||
}
|
||||
|
||||
let stateParameters: StateParameters = { ...button };
|
||||
let stateParameters = { ...button } as StateParameters;
|
||||
const svgPath =
|
||||
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
import submenuStyle from '../scss/submenu.scss';
|
||||
import {
|
||||
@@ -39,7 +39,7 @@ export class FrigateCardSubmenu extends LitElement {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item });
|
||||
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters);
|
||||
const getIcon = (stateParameters: StateParameters): TemplateResult => {
|
||||
if (stateParameters.icon) {
|
||||
return html` <ha-icon
|
||||
@@ -95,7 +95,7 @@ export class FrigateCardSubmenu extends LitElement {
|
||||
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
|
||||
>
|
||||
<ha-icon-button
|
||||
style="${styleMap(this.submenu.style || {})}"
|
||||
style="${styleMap(this.submenu.style as StyleInfo || {})}"
|
||||
class="button"
|
||||
slot="trigger"
|
||||
.label=${this.submenu.title || ''}
|
||||
@@ -205,7 +205,7 @@ export class FrigateCardSubmenuSelect extends LitElement {
|
||||
icon: domainIcon('select'),
|
||||
|
||||
// Pull out the dynamic properties (like icon, and title) from the state.
|
||||
...refreshDynamicStateParameters(this.hass, this.submenuSelect),
|
||||
...refreshDynamicStateParameters(this.hass, this.submenuSelect as StateParameters),
|
||||
|
||||
// Override it with anything explicitly set in the submenuSelect.
|
||||
...this.submenuSelect,
|
||||
|
||||
+34
-26
@@ -170,11 +170,9 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
||||
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
|
||||
|
||||
const rawEndTime = this.media.getEndTime();
|
||||
const endTime = rawStartTime
|
||||
? rawEndTime
|
||||
? getDurationString(rawStartTime, rawEndTime)
|
||||
: localize('event.in_progress')
|
||||
: null;
|
||||
const duration =
|
||||
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
|
||||
const inProgress = this.media.inProgress() ? localize('event.in_progress') : null;
|
||||
|
||||
const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null;
|
||||
const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null;
|
||||
@@ -200,13 +198,18 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
||||
></ha-icon>
|
||||
<span title="${startTime}">${startTime}</span>
|
||||
</div>
|
||||
<div>
|
||||
<ha-icon
|
||||
title=${localize('event.duration')}
|
||||
.icon=${'mdi:clock-outline'}
|
||||
></ha-icon>
|
||||
<span title="${endTime}">${endTime}</span>
|
||||
</div>`
|
||||
${duration || inProgress
|
||||
? html` <div>
|
||||
<ha-icon
|
||||
title=${localize('event.duration')}
|
||||
.icon=${'mdi:clock-outline'}
|
||||
></ha-icon>
|
||||
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
|
||||
${inProgress
|
||||
? html`<span title="${inProgress}">${inProgress}</span>`
|
||||
: ''}
|
||||
</div>`
|
||||
: ''}`
|
||||
: ''}
|
||||
${this.cameraTitle
|
||||
? html` <div>
|
||||
@@ -266,11 +269,9 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
|
||||
|
||||
const rawEndTime = this.media.getEndTime();
|
||||
const endTime = rawStartTime
|
||||
? rawEndTime
|
||||
? getDurationString(rawStartTime, rawEndTime)
|
||||
: localize('event.in_progress')
|
||||
: null;
|
||||
const duration =
|
||||
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
|
||||
const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null;
|
||||
|
||||
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
|
||||
|
||||
@@ -284,17 +285,24 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||
<div class="details">
|
||||
${startTime
|
||||
? html` <div>
|
||||
<ha-icon title=${localize(
|
||||
'recording.start',
|
||||
)} .icon=${'mdi:calendar-clock-outline'}></ha-icon>
|
||||
<ha-icon
|
||||
title=${localize('recording.start')}
|
||||
.icon=${'mdi:calendar-clock-outline'}
|
||||
></ha-icon>
|
||||
<span title="${startTime}">${startTime}</span>
|
||||
</div>
|
||||
<div>
|
||||
<ha-icon title=${localize(
|
||||
'recording.duration',
|
||||
)} .icon=${'mdi:clock-outline'}></ha-icon>
|
||||
<span title="${endTime}">${endTime}</span>
|
||||
</div>`
|
||||
${duration || inProgress
|
||||
? html` <div>
|
||||
<ha-icon
|
||||
title=${localize('recording.duration')}
|
||||
.icon=${'mdi:clock-outline'}
|
||||
></ha-icon>
|
||||
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
|
||||
${inProgress
|
||||
? html`<span title="${inProgress}">${inProgress}</span>`
|
||||
: ''}
|
||||
</div>`
|
||||
: ''}`
|
||||
: ''}
|
||||
${seek
|
||||
? html` <div>
|
||||
|
||||
@@ -525,10 +525,13 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
.selectResultIfFound((media) => media.getID() === properties.item);
|
||||
|
||||
if (!newResults || !newResults.hasSelectedResult()) {
|
||||
// This can happen if this is a recording query (with recorded hours)
|
||||
// and an event is clicked on the timeline, or if the current thumbnails
|
||||
// is a filtered view from the media gallery (i.e. any case where the
|
||||
// thumbnails may not be match the events on the timeline).
|
||||
// This can happen in a few situations:
|
||||
// - If this is a recording query (with recorded hours) and an event is
|
||||
// clicked on the timeline
|
||||
// - If the current thumbnails/results is a filtered view from the media
|
||||
// gallery (i.e. any case where the thumbnails may not be match the
|
||||
// events on the timeline, e.g. in the snapshots viewer but
|
||||
// mini-timeline showing all media).
|
||||
const fullEventView = await this._createViewWithEventMediaQuery(
|
||||
this._createEventMediaQuerys(),
|
||||
{
|
||||
@@ -551,10 +554,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
|
||||
if (view) {
|
||||
view
|
||||
// If the user is clicking something in the timeline, don't
|
||||
// subsequently shift the window (it's pretty jarring).
|
||||
.dispatchChangeEvent(this);
|
||||
view.dispatchChangeEvent(this);
|
||||
|
||||
if (this.view?.is('timeline')) {
|
||||
dispatchFrigateCardEvent(this, 'thumbnails:open');
|
||||
@@ -895,8 +895,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
const mediaStartTime = media?.getStartTime();
|
||||
const mediaEndTime = media?.getEndTime();
|
||||
const mediaWindow: TimelineWindow | null =
|
||||
media && mediaStartTime && mediaEndTime
|
||||
? { start: mediaStartTime, end: mediaEndTime }
|
||||
media && mediaStartTime
|
||||
// If this media has no end time, it's just a "point" in time so the
|
||||
// range effectively starts/ends at the same time.
|
||||
? { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime }
|
||||
: null;
|
||||
const context = this.view.context?.timeline;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import { TitleControlConfig } from '../types.js';
|
||||
|
||||
import titleStyle from '../scss/title-control.scss';
|
||||
@@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public fitInto?: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
public logo?: string;
|
||||
|
||||
protected _toastRef: Ref<PaperToast> = createRef();
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
.text="${this.text}"
|
||||
.fitInto=${this.fitInto}
|
||||
>
|
||||
${this.logo ? html`<img src=${this.logo} />` : ''}
|
||||
</paper-toast>`;
|
||||
}
|
||||
|
||||
@@ -58,7 +61,7 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
/**
|
||||
* Show the toast.
|
||||
*/
|
||||
public hide(): void {
|
||||
public hide(): void {
|
||||
if (this._toastRef.value) {
|
||||
// Set it to false first, to ensure the timer resets.
|
||||
this._toastRef.value.opened = false;
|
||||
@@ -85,7 +88,7 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"frigate-card-title-control": FrigateCardTitleControl
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-title-control': FrigateCardTitleControl;
|
||||
}
|
||||
}
|
||||
|
||||
+89
-22
@@ -45,7 +45,7 @@ import {
|
||||
changeViewToRecentEventsForCameraAndDependents,
|
||||
changeViewToRecentRecordingForCameraAndDependents,
|
||||
} from '../utils/media-to-view.js';
|
||||
import { ViewMedia } from '../view/media.js';
|
||||
import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
@@ -53,6 +53,10 @@ import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
||||
import { playMediaMutingIfNecessary } from '../utils/media.js';
|
||||
import {
|
||||
hideMediaControlsTemporarily,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
} from '../utils/media.js';
|
||||
|
||||
export interface MediaViewerViewContext {
|
||||
seek?: Date;
|
||||
@@ -399,7 +403,13 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
const media =
|
||||
this.view?.queryResults?.getSelectedResult() ??
|
||||
this.view?.queryResults?.getResult(resultCount - 1);
|
||||
if (!media || !this.view || !this.view.queryResults) {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameraManager ||
|
||||
!media ||
|
||||
!this.view ||
|
||||
!this.view.queryResults
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,6 +426,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
media.getCameraID(),
|
||||
);
|
||||
|
||||
return html` <frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||
@@ -426,6 +441,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
this._getPlugins.bind(this),
|
||||
)}
|
||||
.label=${media.getTitle() ?? undefined}
|
||||
.logo=${cameraMetadata?.engineLogo}
|
||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
||||
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@@ -544,30 +560,53 @@ export class FrigateCardViewerProvider
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected _refVideoProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||
protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> =
|
||||
createRef();
|
||||
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
||||
|
||||
public async play(): Promise<void> {
|
||||
playMediaMutingIfNecessary(this._refVideoProvider.value);
|
||||
playMediaMutingIfNecessary(
|
||||
this,
|
||||
this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value,
|
||||
);
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this._refVideoProvider.value?.pause();
|
||||
(this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause();
|
||||
}
|
||||
|
||||
public mute(): void {
|
||||
this._refVideoProvider.value?.mute();
|
||||
if (this._refFrigateCardMediaPlayer.value) {
|
||||
this._refFrigateCardMediaPlayer.value?.mute();
|
||||
} else if (this._refVideoProvider.value) {
|
||||
this._refVideoProvider.value.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
public unmute(): void {
|
||||
this._refVideoProvider.value?.unmute();
|
||||
if (this._refFrigateCardMediaPlayer.value) {
|
||||
this._refFrigateCardMediaPlayer.value?.mute();
|
||||
} else if (this._refVideoProvider.value) {
|
||||
this._refVideoProvider.value.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
public isMuted(): boolean {
|
||||
return this._refVideoProvider.value?.isMuted() ?? true;
|
||||
if (this._refFrigateCardMediaPlayer.value) {
|
||||
return this._refFrigateCardMediaPlayer.value?.isMuted() ?? true;
|
||||
} else if (this._refVideoProvider.value) {
|
||||
return this._refVideoProvider.value.muted;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public seek(seconds: number): void {
|
||||
this._refVideoProvider.value?.seek(seconds);
|
||||
if (this._refFrigateCardMediaPlayer.value) {
|
||||
return this._refFrigateCardMediaPlayer.value.seek(seconds);
|
||||
} else if (this._refVideoProvider.value) {
|
||||
hideMediaControlsTemporarily(this._refVideoProvider.value);
|
||||
this._refVideoProvider.value.currentTime = seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -666,19 +705,47 @@ export class FrigateCardViewerProvider
|
||||
}
|
||||
|
||||
return ViewMediaClassifier.isVideo(this.media)
|
||||
? html`<frigate-card-ha-hls-player
|
||||
${ref(this._refVideoProvider)}
|
||||
allow-exoplayer
|
||||
aria-label="${this.media.getTitle() ?? ''}"
|
||||
?autoplay=${false}
|
||||
controls
|
||||
muted
|
||||
playsinline
|
||||
title="${this.media.getTitle() ?? ''}"
|
||||
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||
.hass=${this.hass}
|
||||
>
|
||||
</frigate-card-ha-hls-player>`
|
||||
? this.media.getVideoContentType() === VideoContentType.HLS
|
||||
? html`<frigate-card-ha-hls-player
|
||||
${ref(this._refFrigateCardMediaPlayer)}
|
||||
allow-exoplayer
|
||||
aria-label="${this.media.getTitle() ?? ''}"
|
||||
?autoplay=${false}
|
||||
controls
|
||||
muted
|
||||
playsinline
|
||||
title="${this.media.getTitle() ?? ''}"
|
||||
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||
.hass=${this.hass}
|
||||
>
|
||||
</frigate-card-ha-hls-player>`
|
||||
: html`
|
||||
<video
|
||||
${ref(this._refVideoProvider)}
|
||||
aria-label="${this.media.getTitle() ?? ''}"
|
||||
title="${this.media.getTitle() ?? ''}"
|
||||
muted
|
||||
controls
|
||||
playsinline
|
||||
?autoplay=${false}
|
||||
@loadedmetadata=${(ev: Event) => {
|
||||
if (ev.target) {
|
||||
hideMediaControlsTemporarily(
|
||||
ev.target as HTMLVideoElement,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
);
|
||||
}
|
||||
}}
|
||||
@loadeddata=${(ev: Event) => {
|
||||
dispatchMediaLoadedEvent(this, ev);
|
||||
}}
|
||||
>
|
||||
<source
|
||||
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
`
|
||||
: html`<img
|
||||
aria-label="${this.media.getTitle() ?? ''}"
|
||||
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
||||
|
||||
+13
-2
@@ -11,17 +11,28 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
|
||||
`${CONF_CAMERAS}.#.frigate.labels` as const;
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const;
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES = `${CONF_CAMERAS}.#.frigate.zones` as const;
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES =
|
||||
`${CONF_CAMERAS}.#.frigate.zones` as const;
|
||||
export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
|
||||
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
|
||||
`${CONF_CAMERAS}.#.go2rtc.stream` as const;
|
||||
export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const;
|
||||
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
|
||||
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
|
||||
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
|
||||
`${CONF_CAMERAS}.#.image.refresh_seconds` as const;
|
||||
export const CONF_CAMERAS_ARRAY_IMAGE_URL = `${CONF_CAMERAS}.#.image.url` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN =
|
||||
`${CONF_CAMERAS}.#.motioneye.images.directory_pattern` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN =
|
||||
`${CONF_CAMERAS}.#.motioneye.images.file_pattern` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN =
|
||||
`${CONF_CAMERAS}.#.motioneye.movies.directory_pattern` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN =
|
||||
`${CONF_CAMERAS}.#.motioneye.movies.file_pattern` as const;
|
||||
export const CONF_CAMERAS_ARRAY_MOTIONEYE_URL =
|
||||
`${CONF_CAMERAS}.#.motioneye.url` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
|
||||
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
|
||||
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
|
||||
`${CONF_CAMERAS}.#.webrtc_card.entity` as const;
|
||||
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
declare module '*.scss';
|
||||
declare module '*.svg';
|
||||
declare module '*.jpg';
|
||||
declare module 'view' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
|
||||
+50
-2
@@ -28,6 +28,11 @@ import {
|
||||
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
||||
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
|
||||
CONF_CAMERAS_ARRAY_TITLE,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
||||
@@ -159,6 +164,10 @@ import {
|
||||
sideLoadHomeAssistantElements,
|
||||
} from './utils/ha';
|
||||
import { setLowPerformanceProfile } from './performance.js';
|
||||
import {
|
||||
MOTIONEYE_ICON_SVG_PATH,
|
||||
MOTIONEYE_ICON_SVG_VIEWBOX,
|
||||
} from './camera-manager/motioneye/icon.js';
|
||||
|
||||
const MENU_BUTTONS = 'buttons';
|
||||
const MENU_CAMERAS = 'cameras';
|
||||
@@ -166,6 +175,7 @@ const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
|
||||
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
|
||||
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
|
||||
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
||||
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
||||
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
||||
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
||||
@@ -888,6 +898,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
icon: {
|
||||
name?: string;
|
||||
path?: string;
|
||||
viewBox?: string;
|
||||
},
|
||||
template: TemplateResult,
|
||||
): TemplateResult {
|
||||
@@ -907,7 +918,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${icon.name
|
||||
? html` <ha-icon .icon=${icon.name}></ha-icon> `
|
||||
: icon.path
|
||||
? html` <ha-svg-icon .path="${icon.path}"></ha-svg-icon> `
|
||||
? html`
|
||||
<ha-svg-icon .viewBox=${icon.viewBox} .path="${icon.path}"></ha-svg-icon>
|
||||
`
|
||||
: ``}
|
||||
<span>${localize(labelPath)}</span>
|
||||
</div>
|
||||
@@ -1411,7 +1424,42 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
),
|
||||
)}
|
||||
`,
|
||||
)}`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_MOTIONEYE,
|
||||
cameraIndex,
|
||||
'config.cameras.motioneye.editor_label',
|
||||
{ path: MOTIONEYE_ICON_SVG_PATH, viewBox: MOTIONEYE_ICON_SVG_VIEWBOX },
|
||||
html`
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
`,
|
||||
)} `,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_LIVE_PROVIDER,
|
||||
|
||||
@@ -57,6 +57,18 @@
|
||||
"image": "Home Assistant images",
|
||||
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
|
||||
},
|
||||
"motioneye": {
|
||||
"editor_label": "MotionEye Options",
|
||||
"images": {
|
||||
"directory_pattern": "Images directory pattern",
|
||||
"file_pattern": "Images file pattern"
|
||||
},
|
||||
"movies": {
|
||||
"directory_pattern": "Movies directory pattern",
|
||||
"file_pattern": "Movies file pattern"
|
||||
},
|
||||
"url": "MotionEye UI URL"
|
||||
},
|
||||
"title": "Title for this camera (Autodetected from entity)",
|
||||
"triggers": {
|
||||
"entities": "Trigger from other entities",
|
||||
@@ -428,14 +440,15 @@
|
||||
"whens": {
|
||||
"past_month": "Past Month",
|
||||
"past_week": "Past Week",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"where": "Where"
|
||||
},
|
||||
"recording": {
|
||||
"camera": "Camera",
|
||||
"duration": "Duration",
|
||||
"in_progress": "In Progress",
|
||||
"events": "Events",
|
||||
"seek": "Seek",
|
||||
"start": "Start"
|
||||
|
||||
@@ -57,6 +57,18 @@
|
||||
"image": "",
|
||||
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)"
|
||||
},
|
||||
"motioneye": {
|
||||
"editor_label": "",
|
||||
"images": {
|
||||
"directory_pattern": "",
|
||||
"file_pattern": ""
|
||||
},
|
||||
"movies": {
|
||||
"directory_pattern": "",
|
||||
"file_pattern": ""
|
||||
},
|
||||
"url": ""
|
||||
},
|
||||
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
|
||||
"triggers": {
|
||||
"entities": "Trigger da altre entità",
|
||||
@@ -427,6 +439,7 @@
|
||||
"camera": "",
|
||||
"duration": "",
|
||||
"events": "Eventi",
|
||||
"in_progress": "In corso",
|
||||
"seek": "Cercare",
|
||||
"start": ""
|
||||
},
|
||||
|
||||
@@ -57,6 +57,18 @@
|
||||
"image": "",
|
||||
"webrtc-card": "Cartão WebRTC (de @AlexxIT)"
|
||||
},
|
||||
"motioneye": {
|
||||
"editor_label": "",
|
||||
"images": {
|
||||
"directory_pattern": "",
|
||||
"file_pattern": ""
|
||||
},
|
||||
"movies": {
|
||||
"directory_pattern": "",
|
||||
"file_pattern": ""
|
||||
},
|
||||
"url": ""
|
||||
},
|
||||
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
|
||||
"triggers": {
|
||||
"entities": "Acionar a partir de outras entidades",
|
||||
@@ -427,6 +439,7 @@
|
||||
"camera": "",
|
||||
"duration": "",
|
||||
"events": "Eventos",
|
||||
"in_progress": "Em andamento",
|
||||
"seek": "Procurar",
|
||||
"start": ""
|
||||
},
|
||||
|
||||
@@ -28,14 +28,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
|
||||
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
|
||||
|
||||
const computeObjectId = (entityId: string): string =>
|
||||
entityId.substr(entityId.indexOf('.') + 1);
|
||||
|
||||
const computeStateName = (stateObj: HassEntity): string =>
|
||||
stateObj.attributes.friendly_name === undefined
|
||||
? computeObjectId(stateObj.entity_id).replace(/_/g, ' ')
|
||||
: stateObj.attributes.friendly_name || '';
|
||||
|
||||
const STREAM_TYPE_HLS = 'hls';
|
||||
const STREAM_TYPE_WEB_RTC = 'web_rtc';
|
||||
|
||||
@@ -100,7 +92,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
.src=${typeof this._connected == 'undefined' || this._connected
|
||||
? computeMJPEGStreamUrl(this.stateObj)
|
||||
: ''}
|
||||
.alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
||||
}
|
||||
|
||||
public isMuted(): boolean {
|
||||
return this._video?.muted() ?? true;
|
||||
return this._video?.muted ?? true;
|
||||
}
|
||||
|
||||
public seek(seconds: number): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
|
||||
// Hide scrollbar: IE and Edge
|
||||
@@ -11,7 +12,9 @@
|
||||
|
||||
--frigate-card-gallery-gap: 3px;
|
||||
--frigate-card-gallery-columns: 4;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
|
||||
grid-auto-rows: min-content;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@use 'dotdotdot.scss';
|
||||
|
||||
:host {
|
||||
min-height: 100%;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
|
||||
@@ -6,4 +6,11 @@
|
||||
paper-toast {
|
||||
max-width: unset;
|
||||
min-width: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
paper-toast img {
|
||||
max-height: 24px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
frigate-card-ha-hls-player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
+54
-22
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
CallServiceActionConfig,
|
||||
ConfirmationRestrictionConfig,
|
||||
CustomActionConfig,
|
||||
HomeAssistant,
|
||||
LovelaceCardConfig,
|
||||
MoreInfoActionConfig,
|
||||
@@ -95,7 +94,7 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
|
||||
export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||
|
||||
const ENGINES = ['auto', 'frigate', 'generic'] as const;
|
||||
const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const;
|
||||
|
||||
export class FrigateCardError extends Error {
|
||||
context?: unknown;
|
||||
@@ -182,15 +181,13 @@ const moreInfoActionSchema = schemaForType<
|
||||
action: z.literal('more-info'),
|
||||
}),
|
||||
);
|
||||
const customActionSchema = schemaForType<
|
||||
CustomActionConfig & ExtendedConfirmationRestrictionConfig
|
||||
>()(
|
||||
actionBaseSchema
|
||||
.extend({
|
||||
action: z.literal('fire-dom-event'),
|
||||
})
|
||||
.passthrough(),
|
||||
);
|
||||
|
||||
const customActionSchema = actionBaseSchema
|
||||
.extend({
|
||||
action: z.literal('fire-dom-event'),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const noActionSchema = schemaForType<
|
||||
NoActionConfig & ExtendedConfirmationRestrictionConfig
|
||||
>()(
|
||||
@@ -450,19 +447,29 @@ const jsmpegConfigSchema = z.object({
|
||||
* Camera configuration section
|
||||
*/
|
||||
const cameraConfigDefault = {
|
||||
live_provider: 'auto' as const,
|
||||
engine: 'auto' as const,
|
||||
frigate: {
|
||||
client_id: 'frigate' as const,
|
||||
},
|
||||
dependencies: {
|
||||
all_cameras: false,
|
||||
cameras: [],
|
||||
},
|
||||
engine: 'auto' as const,
|
||||
frigate: {
|
||||
client_id: 'frigate' as const,
|
||||
},
|
||||
hide: false,
|
||||
image: {
|
||||
refresh_seconds: 1,
|
||||
},
|
||||
hide: false,
|
||||
live_provider: 'auto' as const,
|
||||
motioneye: {
|
||||
images: {
|
||||
directory_pattern: '%Y-%m-%d' as const,
|
||||
file_pattern: '%H-%M-%S' as const,
|
||||
},
|
||||
movies: {
|
||||
directory_pattern: '%Y-%m-%d' as const,
|
||||
file_pattern: '%H-%M-%S' as const,
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
motion: false,
|
||||
occupancy: true,
|
||||
@@ -505,7 +512,6 @@ const cameraConfigSchema = z
|
||||
engine: z.enum(ENGINES).default('auto'),
|
||||
frigate: z
|
||||
.object({
|
||||
// No URL validation to allow relative URLs within HA (e.g. Frigate addon).
|
||||
url: z.string().optional(),
|
||||
client_id: z.string().default(cameraConfigDefault.frigate.client_id),
|
||||
camera_name: z.string().optional(),
|
||||
@@ -513,6 +519,35 @@ const cameraConfigSchema = z
|
||||
zones: z.string().array().optional(),
|
||||
})
|
||||
.default(cameraConfigDefault.frigate),
|
||||
motioneye: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
images: z
|
||||
.object({
|
||||
directory_pattern: z
|
||||
.string()
|
||||
.includes('%')
|
||||
.default(cameraConfigDefault.motioneye.images.directory_pattern),
|
||||
file_pattern: z
|
||||
.string()
|
||||
.includes('%')
|
||||
.default(cameraConfigDefault.motioneye.images.file_pattern),
|
||||
})
|
||||
.default(cameraConfigDefault.motioneye.images),
|
||||
movies: z
|
||||
.object({
|
||||
directory_pattern: z
|
||||
.string()
|
||||
.includes('%')
|
||||
.default(cameraConfigDefault.motioneye.movies.directory_pattern),
|
||||
file_pattern: z
|
||||
.string()
|
||||
.includes('%')
|
||||
.default(cameraConfigDefault.motioneye.movies.file_pattern),
|
||||
})
|
||||
.default(cameraConfigDefault.motioneye.movies),
|
||||
})
|
||||
.default(cameraConfigDefault.motioneye),
|
||||
|
||||
// Live provider options.
|
||||
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
||||
@@ -1321,10 +1356,7 @@ export const frigateCardConfigSchema = z.object({
|
||||
|
||||
// Card ID (used for query string commands). Restrict contents to only values
|
||||
// that be easily used in a URL.
|
||||
card_id: z
|
||||
.string()
|
||||
.regex(/^\w+$/)
|
||||
.optional(),
|
||||
card_id: z.string().regex(/^\w+$/).optional(),
|
||||
|
||||
// Stock lovelace card config.
|
||||
type: z.string(),
|
||||
|
||||
@@ -196,3 +196,7 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||
};
|
||||
|
||||
export const isValidDate = (date: Date): boolean => {
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
+22
-13
@@ -10,23 +10,32 @@ export const downloadMedia = async (
|
||||
cameraManager: CameraManager,
|
||||
media: ViewMedia,
|
||||
): Promise<void> => {
|
||||
const path = cameraManager.getMediaDownloadPath(media);
|
||||
if (!path) {
|
||||
const download = await cameraManager.getMediaDownloadPath(hass, media);
|
||||
if (!download) {
|
||||
throw new FrigateCardError(localize('error.download_no_media'));
|
||||
}
|
||||
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, path);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
let finalURL = download.endpoint;
|
||||
if (download.sign) {
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, download.endpoint);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new FrigateCardError(localize('error.download_sign_failed'));
|
||||
}
|
||||
finalURL = response;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new FrigateCardError(localize('error.download_sign_failed'));
|
||||
}
|
||||
// The download attribute only works on the same origin.
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes
|
||||
const isSameOrigin = new URL(finalURL).origin === window.location.origin;
|
||||
|
||||
if (
|
||||
!isSameOrigin ||
|
||||
navigator.userAgent.startsWith('Home Assistant/') ||
|
||||
navigator.userAgent.startsWith('HomeAssistant/')
|
||||
) {
|
||||
@@ -36,13 +45,13 @@ export const downloadMedia = async (
|
||||
// User-agents are specified here:
|
||||
// - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107
|
||||
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
|
||||
window.open(response, '_blank');
|
||||
window.open(finalURL, '_blank');
|
||||
} else {
|
||||
// Use the HTML5 download attribute to prevent a new window from
|
||||
// temporarily opening.
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('download', '');
|
||||
link.href = response;
|
||||
link.setAttribute('download', 'download');
|
||||
link.href = finalURL;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
+12
-13
@@ -11,23 +11,22 @@ export const getEndpointAddressOrDispatchError = async (
|
||||
endpoint: CameraEndpoint,
|
||||
expires?: number,
|
||||
): Promise<string | null> => {
|
||||
let address: string | null;
|
||||
if (!endpoint.sign) {
|
||||
address = endpoint.endpoint;
|
||||
} else {
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
address = response ? response.replace(/^http/i, 'ws') : null;
|
||||
return endpoint.endpoint;
|
||||
}
|
||||
|
||||
if (!address) {
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
|
||||
return null;
|
||||
}
|
||||
return address;
|
||||
|
||||
return response.replace(/^http/i, 'ws');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import add from 'date-fns/add';
|
||||
import { homeAssistantWSRequest } from '..';
|
||||
import { MemoryRequestCache } from '../../../camera-manager/cache';
|
||||
import { allPromises } from '../../basic';
|
||||
import {
|
||||
BrowseMedia,
|
||||
browseMediaSchema,
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
RichBrowseMedia,
|
||||
} from './types';
|
||||
|
||||
type BrowseMediaCache<M> = MemoryRequestCache<string, RichBrowseMedia<M>>;
|
||||
type RichMetadataGenerator<M> = (
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<M>,
|
||||
) => M | null;
|
||||
|
||||
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
|
||||
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
|
||||
|
||||
export interface BrowseMediaStep<M> {
|
||||
// The targets to start the media walk from.
|
||||
targets: BrowseMediaTarget<M>[];
|
||||
|
||||
// All children of the target have the metadata generator applied to them
|
||||
// first.
|
||||
metadataGenerator?: RichMetadataGenerator<M>;
|
||||
|
||||
// If those children pass this matcher, then they will be included in the
|
||||
// output.
|
||||
matcher: RichBrowseMediaPredicate<M>;
|
||||
|
||||
// advance will be called to generate a next step (or null if the child should
|
||||
// just be included straight through to the output with no further steps).
|
||||
advance?: BrowseMediaStepAdvancer<M>;
|
||||
}
|
||||
|
||||
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
|
||||
|
||||
export class BrowseMediaManager<M> {
|
||||
protected _cache: BrowseMediaCache<M>;
|
||||
|
||||
constructor(cache: BrowseMediaCache<M>) {
|
||||
this._cache = cache;
|
||||
}
|
||||
|
||||
// Walk down a browse media tree according to instructions included in `steps`.
|
||||
public async walkBrowseMedias(
|
||||
hass: HomeAssistant,
|
||||
steps: BrowseMediaStep<M>[] | null,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>[]> {
|
||||
if (!steps || !steps.length) {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
await allPromises(
|
||||
steps,
|
||||
async (step) => await this._walkBrowseMedia(hass, step, options),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
|
||||
protected async _walkBrowseMedia(
|
||||
hass: HomeAssistant,
|
||||
step: BrowseMediaStep<M>,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>[]> {
|
||||
const media = await allPromises(
|
||||
step.targets,
|
||||
async (target) =>
|
||||
await this._browseMedia(hass, target, {
|
||||
useCache: options?.useCache,
|
||||
metadataGenerator: step.metadataGenerator,
|
||||
}),
|
||||
);
|
||||
|
||||
const newTargets: RichBrowseMedia<M>[] = [];
|
||||
for (const parent of media) {
|
||||
for (const child of parent.children ?? []) {
|
||||
if (step.matcher(child)) {
|
||||
newTargets.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextSteps = step.advance ? step.advance(newTargets) : null;
|
||||
if (!nextSteps || !nextSteps.length) {
|
||||
return newTargets;
|
||||
}
|
||||
|
||||
const targetsIncludedInNextSteps = new Set(
|
||||
nextSteps.map((nextStep) => nextStep.targets).flat(),
|
||||
);
|
||||
const finished: RichBrowseMedia<M>[] = [];
|
||||
|
||||
// Any new target that doesn't have a proposed 'next step' is assumed to be
|
||||
// ready to return.
|
||||
for (const target of newTargets) {
|
||||
if (!targetsIncludedInNextSteps.has(target)) {
|
||||
finished.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
const downstream = await this.walkBrowseMedias(hass, nextSteps, options);
|
||||
return finished.concat(downstream);
|
||||
}
|
||||
|
||||
protected async _browseMedia(
|
||||
hass: HomeAssistant,
|
||||
target: string | RichBrowseMedia<M>,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
metadataGenerator?: RichMetadataGenerator<M>;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>> {
|
||||
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
|
||||
const cachedResult =
|
||||
options?.useCache ?? true ? this._cache.get(mediaContentID) : null;
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const request = {
|
||||
type: 'media_source/browse_media',
|
||||
media_content_id: mediaContentID,
|
||||
};
|
||||
const browseMedia = (await homeAssistantWSRequest(
|
||||
hass,
|
||||
browseMediaSchema,
|
||||
request,
|
||||
)) as RichBrowseMedia<M>;
|
||||
|
||||
if (options?.metadataGenerator) {
|
||||
for (const child of browseMedia.children ?? []) {
|
||||
child._metadata =
|
||||
options.metadataGenerator(
|
||||
child,
|
||||
typeof target === 'object' ? target : undefined,
|
||||
) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.useCache ?? true) {
|
||||
this._cache.set(
|
||||
mediaContentID,
|
||||
browseMedia,
|
||||
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||
);
|
||||
}
|
||||
return browseMedia;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Recursive type, cannot use type interference:
|
||||
// See: https://github.com/colinhacks/zod#recursive-types
|
||||
//
|
||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L90
|
||||
export interface BrowseMedia {
|
||||
title: string;
|
||||
media_class: string;
|
||||
media_content_type: string;
|
||||
media_content_id: string;
|
||||
can_play: boolean;
|
||||
can_expand: boolean;
|
||||
children_media_class?: string | null;
|
||||
thumbnail: string | null;
|
||||
children?: BrowseMedia[] | null;
|
||||
}
|
||||
|
||||
export const browseMediaSchema: z.ZodSchema<BrowseMedia> = z.lazy(() =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
media_class: z.string(),
|
||||
media_content_type: z.string(),
|
||||
media_content_id: z.string(),
|
||||
can_play: z.boolean(),
|
||||
can_expand: z.boolean(),
|
||||
children_media_class: z.string().nullable().optional(),
|
||||
thumbnail: z.string().nullable(),
|
||||
children: z.array(browseMediaSchema).nullable().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export interface RichBrowseMedia<M> extends BrowseMedia {
|
||||
_metadata?: M;
|
||||
children?: RichBrowseMedia<M>[] | null;
|
||||
}
|
||||
|
||||
export const MEDIA_CLASS_VIDEO = 'video' as const;
|
||||
export const MEDIA_CLASS_IMAGE = 'image' as const;
|
||||
|
||||
export const BROWSE_MEDIA_CACHE_SECONDS = 60 as const;
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
export const entitySchema = z.object({
|
||||
config_entry_id: z.string().nullable(),
|
||||
device_id: z.string().nullable(),
|
||||
disabled_by: z.string().nullable(),
|
||||
entity_id: z.string(),
|
||||
hidden_by: z.string().nullable(),
|
||||
|
||||
@@ -354,12 +354,13 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
|
||||
* location will be the Chromecast receiver, not HA).
|
||||
* @param url The media URL
|
||||
*/
|
||||
export const canonicalizeHAURL = (
|
||||
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
|
||||
export function canonicalizeHAURL(
|
||||
hass: ExtendedHomeAssistant,
|
||||
url?: string,
|
||||
): string | null => {
|
||||
): string | null {
|
||||
if (hass && url && url.startsWith('/')) {
|
||||
return hass.hassUrl(url);
|
||||
}
|
||||
return url ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
+8
-6
@@ -31,20 +31,22 @@ export const hideMediaControlsTemporarily = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Play a piece of media, muting it if necessary.
|
||||
* @param underlyingPlayer
|
||||
*
|
||||
* @param player The Frigate Card Media Player object.
|
||||
* @param video An underlying video or media player upon which to call play.
|
||||
*/
|
||||
export const playMediaMutingIfNecessary = async (
|
||||
player?: FrigateCardMediaPlayer,
|
||||
player: FrigateCardMediaPlayer,
|
||||
video?: HTMLVideoElement | FrigateCardMediaPlayer,
|
||||
): Promise<void> => {
|
||||
// If the play call fails, and the media is not already muted, mute it first
|
||||
// and then try again. This works around some browsers that prevent
|
||||
// auto-play unless the video is muted.
|
||||
if (player?.play) {
|
||||
player.play().catch((ev) => {
|
||||
if (video?.play) {
|
||||
video.play().catch((ev) => {
|
||||
if (ev.name === 'NotAllowedError' && !player.isMuted()) {
|
||||
player.mute();
|
||||
player.play().catch();
|
||||
video.play().catch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+20
-18
@@ -2,6 +2,11 @@ import { Task } from '@lit-labs/task';
|
||||
import { ReactiveControllerHost } from '@lit/reactive-element';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
// See: https://github.com/sindresorhus/is-absolute-url
|
||||
// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
|
||||
// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
|
||||
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
|
||||
|
||||
/**
|
||||
* Fetch a thumbnail URL and return a data URL.
|
||||
* @param hass Home Assistant object.
|
||||
@@ -12,10 +17,10 @@ const fetchThumbnail = async (
|
||||
hass: HomeAssistant,
|
||||
thumbnailURL: string,
|
||||
): Promise<string | null> => {
|
||||
if (!hass) {
|
||||
if (!hass || !thumbnailURL) {
|
||||
return null;
|
||||
}
|
||||
if (thumbnailURL?.startsWith('data:')) {
|
||||
if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) {
|
||||
return thumbnailURL;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -57,21 +62,18 @@ export const createFetchThumbnailTask = (
|
||||
getThumbnailURL: () => string | undefined,
|
||||
autoRun = true,
|
||||
): Task<FetchThumbnailTaskArgs, string | null> => {
|
||||
return new Task(
|
||||
host,
|
||||
{
|
||||
// Do not re-run the task if hass changes, unless it was previously undefined.
|
||||
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
||||
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
||||
string | null
|
||||
> => {
|
||||
const hass = getHASS();
|
||||
if (!haveHASS || !hass || !thumbnailURL) {
|
||||
return null;
|
||||
}
|
||||
return fetchThumbnail(hass, thumbnailURL);
|
||||
},
|
||||
autoRun: autoRun,
|
||||
return new Task(host, {
|
||||
// Do not re-run the task if hass changes, unless it was previously undefined.
|
||||
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
||||
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
||||
string | null
|
||||
> => {
|
||||
const hass = getHASS();
|
||||
if (!haveHASS || !hass || !thumbnailURL) {
|
||||
return null;
|
||||
}
|
||||
return fetchThumbnail(hass, thumbnailURL);
|
||||
},
|
||||
);
|
||||
autoRun: autoRun,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
|
||||
|
||||
export enum VideoContentType {
|
||||
MP4 = "mp4",
|
||||
HLS = "hls",
|
||||
}
|
||||
|
||||
export class ViewMedia {
|
||||
protected _mediaType: ViewMediaType;
|
||||
protected _cameraID: string;
|
||||
@@ -17,6 +22,9 @@ export class ViewMedia {
|
||||
public getMediaType(): ViewMediaType {
|
||||
return this._mediaType;
|
||||
}
|
||||
public getVideoContentType(): VideoContentType | null {
|
||||
return null;
|
||||
}
|
||||
public getID(): string | null {
|
||||
return null;
|
||||
}
|
||||
@@ -26,6 +34,9 @@ export class ViewMedia {
|
||||
public getEndTime(): Date | null {
|
||||
return null;
|
||||
}
|
||||
public inProgress(): boolean | null {
|
||||
return null;
|
||||
}
|
||||
public getContentID(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -689,15 +689,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/plugin-image@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "@rollup/plugin-image@npm:2.1.1"
|
||||
"@rollup/plugin-image@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "@rollup/plugin-image@npm:3.0.2"
|
||||
dependencies:
|
||||
"@rollup/pluginutils": ^3.1.0
|
||||
mini-svg-data-uri: ^1.2.3
|
||||
"@rollup/pluginutils": ^5.0.1
|
||||
mini-svg-data-uri: ^1.4.4
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0 || ^2.0.0
|
||||
checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
checksum: f9d8f587f10c51398fa8c23f1543e3073f969cf7e4acd7f401e02a3e3752702a9eb289ddb14009733ce37d04474c549aba9e7d13ebf50e26226266ba51546b69
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -763,6 +766,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/pluginutils@npm:^5.0.1":
|
||||
version: 5.0.2
|
||||
resolution: "@rollup/pluginutils@npm:5.0.2"
|
||||
dependencies:
|
||||
"@types/estree": ^1.0.0
|
||||
estree-walker: ^2.0.2
|
||||
picomatch: ^2.3.1
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
checksum: edea15e543bebc7dcac3b0ac8bc7b8e8e6dbd46e2864dbe5dd28072de1fbd5b0e10d545a610c0edaa178e8a7ac432e2a2a52e547ece1308471412caba47db8ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0":
|
||||
version: 2.22.2
|
||||
resolution: "@stencil/core@npm:2.22.2"
|
||||
@@ -814,7 +833,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:*":
|
||||
"@types/estree@npm:*, @types/estree@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@types/estree@npm:1.0.0"
|
||||
checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443
|
||||
@@ -2251,7 +2270,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"estree-walker@npm:^2.0.1":
|
||||
"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "estree-walker@npm:2.0.2"
|
||||
checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc
|
||||
@@ -2418,7 +2437,7 @@ __metadata:
|
||||
"@lit-labs/task": ^1.1.3
|
||||
"@rollup/plugin-babel": ^5.3.1
|
||||
"@rollup/plugin-commonjs": ^22.0.2
|
||||
"@rollup/plugin-image": ^2.1.1
|
||||
"@rollup/plugin-image": ^3.0.2
|
||||
"@rollup/plugin-json": ^4.1.0
|
||||
"@rollup/plugin-node-resolve": ^13.3.0
|
||||
"@rollup/plugin-replace": ^4.0.0
|
||||
@@ -2464,7 +2483,7 @@ __metadata:
|
||||
vis-util: ^5.0.2
|
||||
web-dialog: ^0.0.11
|
||||
xss: ^1.0.14
|
||||
zod: ^3.20.6
|
||||
zod: ^3.21.4
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -3504,7 +3523,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mini-svg-data-uri@npm:^1.2.3":
|
||||
"mini-svg-data-uri@npm:^1.4.4":
|
||||
version: 1.4.4
|
||||
resolution: "mini-svg-data-uri@npm:1.4.4"
|
||||
bin:
|
||||
@@ -5483,9 +5502,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.20.6":
|
||||
version: 3.20.6
|
||||
resolution: "zod@npm:3.20.6"
|
||||
checksum: 804b1934b8b5e2fa3750bec90043e8118b201f330b9957b8b768389a971acadf812d2060cf62921086512dab4af691d10490acb03333da58fc485c0791893c89
|
||||
"zod@npm:^3.21.4":
|
||||
version: 3.21.4
|
||||
resolution: "zod@npm:3.21.4"
|
||||
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
Reference in New Issue
Block a user