Merge pull request #1022 from dermotduffy/motioneye

Add support for MotionEye [Proof of concept for richer support for non-Frigate cameras]
This commit is contained in:
Dermot Duffy
2023-03-26 19:08:03 -07:00
committed by GitHub
56 changed files with 1860 additions and 254 deletions
+49 -1
View File
@@ -137,6 +137,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
| - | - | - | - | - | - | - | - | | - | - | - | - | - | - | - | - |
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | |`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: |
|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | |`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|`motioneye`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: |
##### Live providers supported per Engine ##### Live providers supported per Engine
@@ -144,6 +145,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
| - | - | - | - | - | - | | - | - | - | - | - | - |
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | |`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | |`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: |
|`motioneye`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
<a name="camera-frigate-configuration"></a> <a name="camera-frigate-configuration"></a>
@@ -159,11 +161,49 @@ cameras:
| Option | Default | Overridable | Description | | Option | Default | Overridable | Description |
| - | - | - | - | | - | - | - | - |
| `camera_name` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view.| | `camera_name` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view.|
| `url` | | :white_check_mark: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. All other communication with Frigate goes via Home Assistant. | | `url` | | :white_check_mark: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Camera UI` menu button. All other communication with Frigate goes via Home Assistant. |
| `labels` | | :white_check_mark: | An array of Frigate labels used to filter events (clips & snapshots), e.g. [`person`, `car`].| | `labels` | | :white_check_mark: | An array of Frigate labels used to filter events (clips & snapshots), e.g. [`person`, `car`].|
| `zones` | | :white_check_mark: | An array of Frigates zones used to filter events (clips & snapshots), e.g. [`front_door`, `front_steps`].| | `zones` | | :white_check_mark: | An array of Frigates zones used to filter events (clips & snapshots), e.g. [`front_door`, `front_steps`].|
| `client_id` | `frigate` | :white_check_mark: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).| | `client_id` | `frigate` | :white_check_mark: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).|
#### Camera MotionEye configuration
The `motioneye` block configures options for a MotionEye camera. This configuration is included as part of a camera entry in the `cameras` array.
```yaml
cameras:
- motioneye:
```
| Option | Default | Overridable | Description |
| - | - | - | - |
| `url` | | :white_check_mark: | The URL of the MotionEye server. If set, this value will be (exclusively) used for a `Camera UI` menu button. |
| `images` | | :white_check_mark: | Configure how MotionEye images are consumed. See below. |
| `movies` | | :white_check_mark: | Configure how MotionEye movies are consumed. See below. |
#### Camera MotionEye images and movies configuration
The `images` and `movies` block configures options for a MotionEye camera. All
options for `images` and `movies` are under their respective blocks. The options
for both are the same.
```yaml
cameras:
- motioneye:
images:
```
```yaml
cameras:
- motioneye:
movies:
```
| Option | Default | Overridable | Description |
| - | - | - | - |
| `directory_pattern` | `%Y-%m-%d` | :white_check_mark: | The directory that motionEye is configured to store media into. May contain multiple sub-directories separated by `/`. Path must encode the date of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
| `file_pattern` | `%H-%M-%S` | :white_check_mark: | Within a directory (as matched by `directory_pattern`) the media items must exist and match this pattern. `file_pattern` must encode the time of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
#### Live Provider: Camera go2rtc configuration #### Live Provider: Camera go2rtc configuration
The `go2rtc` block configures use of the `go2rtc` live provider. This configuration is included as part of a camera entry in the `cameras` array. The `go2rtc` block configures use of the `go2rtc` live provider. This configuration is included as part of a camera entry in the `cameras` array.
@@ -1458,6 +1498,14 @@ cameras:
live_provider: image live_provider: image
image: image:
refresh_seconds: 1 refresh_seconds: 1
- camera_entity: camera.office_motioneye
motioneye:
images:
directory_pattern: '%Y-%m-%d'
file_pattern: '%H-%M-%S'
movies:
directory_pattern: '%Y-%m-%d'
file_pattern: '%H-%M-%S'
``` ```
</details> </details>
+2 -2
View File
@@ -43,7 +43,7 @@
"vis-util": "^5.0.2", "vis-util": "^5.0.2",
"web-dialog": "^0.0.11", "web-dialog": "^0.0.11",
"xss": "^1.0.14", "xss": "^1.0.14",
"zod": "^3.20.6" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.19.0", "@babel/core": "^7.19.0",
@@ -51,7 +51,7 @@
"@babel/plugin-proposal-decorators": "^7.19.0", "@babel/plugin-proposal-decorators": "^7.19.0",
"@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^22.0.2", "@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-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0", "@rollup/plugin-replace": "^4.0.0",
@@ -0,0 +1,215 @@
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,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
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';
import { BrowseMediaViewMediaFactory } from './media';
/**
* 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.
*/
export const isMediaWithinDates = (
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,
},
);
};
export const getViewMediaFromBrowseMediaArray = (
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
): ViewMedia[] | null => {
const lookup: Map<string, ViewMedia> = new Map();
for (const browseMediaItem of browseMedia) {
const cameraID = browseMediaItem._metadata?.cameraID;
if (!cameraID) {
continue;
}
const mediaType =
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
? 'clip'
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
? 'snapshot'
: null;
if (!mediaType) {
continue;
}
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
mediaType,
browseMediaItem,
cameraID,
);
if (media) {
const id = media.getID();
const existing = lookup.get(id);
// De-duplicate events with precisely the same ID (same
// hour/minute/second) choosing clip > snapshot.
if (
!existing ||
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
) {
lookup.set(id, media);
}
}
}
return [...lookup.values()];
};
/**
* 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,
},
];
}
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,
};
}
}
+85
View File
@@ -0,0 +1,85 @@
import format from 'date-fns/format';
import isEqual from 'lodash-es/isEqual';
import { formatDateAndTime } from '../../utils/basic';
import { 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>;
protected _id: string;
constructor(
mediaType: ViewMediaType,
cameraID: string,
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
) {
super(mediaType, cameraID);
this._browseMedia = browseMedia;
// Generate a custom ID that uses the start date (to allow multiple
// BrowseMedia objects (e.g. images and movies) to be de-duplicated).
if (browseMedia._metadata?.startDate) {
this._id = `${cameraID}/${format(
browseMedia._metadata.startDate,
'yyyy-MM-dd HH:mm:ss',
)}`;
} else {
this._id = browseMedia.media_content_id;
}
}
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._id;
}
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);
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface BrowseMediaMetadata {
cameraID: string;
startDate: Date;
endDate: Date;
}
+1 -1
View File
@@ -16,7 +16,7 @@ interface CameraManagerCache<Request, Response> {
set(request: Request, response: Response, expiry?: Date): void; set(request: Request, response: Response, expiry?: Date): void;
} }
class MemoryRequestCache<Request, Response> export class MemoryRequestCache<Request, Response>
implements CameraManagerCache<Request, Response> implements CameraManagerCache<Request, Response>
{ {
protected _data: RequestCacheItem<Request, Response>[] = []; protected _data: RequestCacheItem<Request, Response>[] = [];
+19 -1
View File
@@ -1,25 +1,32 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { CameraConfig, CardWideConfig } from '../types'; 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 { EntityRegistryManager } from '../utils/ha/entity-registry';
import { Entity } from '../utils/ha/entity-registry/types'; 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 { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error'; import { CameraInitializationError } from './error';
import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
import { GenericCameraManagerEngine } from './generic/engine-generic'; import { GenericCameraManagerEngine } from './generic/engine-generic';
import { MotionEyeCameraManagerEngine } from './motioneye/engine-motioneye';
import { Engine } from './types'; import { Engine } from './types';
export class CameraManagerEngineFactory { export class CameraManagerEngineFactory {
protected _entityRegistryManager: EntityRegistryManager; protected _entityRegistryManager: EntityRegistryManager;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _cardWideConfig: CardWideConfig; protected _cardWideConfig: CardWideConfig;
constructor( constructor(
entityRegistryManager: EntityRegistryManager, entityRegistryManager: EntityRegistryManager,
resolvedMediaCache: ResolvedMediaCache,
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
) { ) {
this._entityRegistryManager = entityRegistryManager; this._entityRegistryManager = entityRegistryManager;
this._cardWideConfig = cardWideConfig; this._cardWideConfig = cardWideConfig;
this._resolvedMediaCache = resolvedMediaCache;
} }
public createEngine(engine: Engine): CameraManagerEngine | null { public createEngine(engine: Engine): CameraManagerEngine | null {
@@ -35,6 +42,12 @@ export class CameraManagerEngineFactory {
new RequestCache(), new RequestCache(),
); );
break; break;
case Engine.MotionEye:
cameraManagerEngine = new MotionEyeCameraManagerEngine(
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
this._resolvedMediaCache,
new RequestCache(),
);
} }
return cameraManagerEngine; return cameraManagerEngine;
} }
@@ -50,6 +63,8 @@ export class CameraManagerEngineFactory {
let engine: Engine | null = null; let engine: Engine | null = null;
if (cameraConfig.engine === 'frigate') { if (cameraConfig.engine === 'frigate') {
engine = Engine.Frigate; engine = Engine.Frigate;
} else if (cameraConfig.engine === 'motioneye') {
engine = Engine.MotionEye;
} else if (cameraConfig.engine === 'auto') { } else if (cameraConfig.engine === 'auto') {
const cameraEntity = cameraConfig.camera_entity; const cameraEntity = cameraConfig.camera_entity;
@@ -70,6 +85,9 @@ export class CameraManagerEngineFactory {
case 'frigate': case 'frigate':
engine = Engine.Frigate; engine = Engine.Frigate;
break; break;
case 'motioneye':
engine = Engine.MotionEye;
break;
default: default:
engine = Engine.Generic; engine = Engine.Generic;
} }
+7 -2
View File
@@ -1,5 +1,5 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types'; import { CameraConfig, ExtendedHomeAssistant } from '../types';
import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { import {
@@ -24,6 +24,7 @@ import {
MediaMetadataQuery, MediaMetadataQuery,
MediaMetadataQueryResultsMap, MediaMetadataQueryResultsMap,
EngineOptions, EngineOptions,
CameraEndpoint,
} from './types'; } from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
@@ -90,7 +91,11 @@ export interface CameraManagerEngine {
results: QueryReturnType<RecordingQuery>, results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null; ): ViewMedia[] | null;
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null; getMediaDownloadPath(
hass: ExtendedHomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null>;
favoriteMedia( favoriteMedia(
hass: HomeAssistant, 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

+20 -15
View File
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add'; import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour'; import endOfHour from 'date-fns/endOfHour';
import startOfHour from 'date-fns/startOfHour'; import startOfHour from 'date-fns/startOfHour';
import { CameraConfig, CardWideConfig } from '../../types'; import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import { RecordingSegmentsCache, RequestCache } from '../cache'; import { RecordingSegmentsCache, RequestCache } from '../cache';
import { import {
@@ -80,6 +80,7 @@ import { localize } from '../../localize/localize';
import uniq from 'lodash-es/uniq'; import uniq from 'lodash-es/uniq';
import format from 'date-fns/format'; import format from 'date-fns/format';
import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { GenericCameraManagerEngine } from '../generic/engine-generic';
import frigateLogo from './assets/frigate-logo-dark.svg';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
@@ -302,26 +303,32 @@ export class FrigateCameraManagerEngine
return null; return null;
} }
public getMediaDownloadPath( public async getMediaDownloadPath(
_hass: ExtendedHomeAssistant,
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
media: ViewMedia, media: ViewMedia,
): string | null { ): Promise<CameraEndpoint | null> {
let path: string | null = null;
if (FrigateViewMediaClassifier.isFrigateEvent(media)) { if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
path = return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` + `/api/frigate/${cameraConfig.frigate.client_id}` +
`/notifications/${media.getID()}/` + `/notifications/${media.getID()}/` +
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`; `?download=true`,
sign: true,
};
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) { } else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
path = return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` + `/api/frigate/${cameraConfig.frigate.client_id}` +
`/recording/${cameraConfig.frigate.camera_name}` + `/recording/${cameraConfig.frigate.camera_name}` +
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + `/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
`?download=true`; `?download=true`,
sign: true,
};
} }
return path; return null;
} }
public generateDefaultEventQuery( public generateDefaultEventQuery(
@@ -1097,6 +1104,7 @@ export class FrigateCameraManagerEngine
cameraConfig.id ?? cameraConfig.id ??
'', '',
icon: metadata.icon, icon: metadata.icon,
engineLogo: frigateLogo,
}; };
} }
@@ -1178,7 +1186,7 @@ export class FrigateCameraManagerEngine
}; };
const getWebRTCCard = (): CameraEndpoint | null => { 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: // setup as per:
// https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/ // https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/
// //
@@ -1194,14 +1202,11 @@ export class FrigateCameraManagerEngine
const jsmpeg = getJSMPEG(); const jsmpeg = getJSMPEG();
const webrtcCard = getWebRTCCard(); const webrtcCard = getWebRTCCard();
return ui || go2rtc || jsmpeg return {
? {
...(ui && { ui: ui }), ...(ui && { ui: ui }),
...(go2rtc && { go2rtc: go2rtc }), ...(go2rtc && { go2rtc: go2rtc }),
...(jsmpeg && { jsmpeg: jsmpeg }), ...(jsmpeg && { jsmpeg: jsmpeg }),
...(jsmpeg && { jsmpeg: jsmpeg }),
...(webrtcCard && { webrtcCard: webrtcCard }), ...(webrtcCard && { webrtcCard: webrtcCard }),
} };
: null;
} }
} }
+14 -3
View File
@@ -6,6 +6,7 @@ import {
EventViewMedia, EventViewMedia,
RecordingViewMedia, RecordingViewMedia,
ViewMediaType, ViewMediaType,
VideoContentType,
} from '../../view/media'; } from '../../view/media';
import { FrigateEvent, FrigateRecording } from './types'; import { FrigateEvent, FrigateRecording } from './types';
import { import {
@@ -42,15 +43,20 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
this._subLabels = subLabels ?? null; this._subLabels = subLabels ?? null;
} }
public hasClip(): boolean {
return !!this._event.has_clip;
}
public getStartTime(): Date { public getStartTime(): Date {
return fromUnixTime(this._event.start_time); return fromUnixTime(this._event.start_time);
} }
public getEndTime(): Date | null { public getEndTime(): Date | null {
return this._event.end_time ? fromUnixTime(this._event.end_time) : 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 { public getID(): string {
return this._event.id; return this._event.id;
} }
@@ -123,6 +129,11 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
public getEndTime(): Date { public getEndTime(): Date {
return this._recording.endTime; 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 { public getContentID(): string | null {
return this._contentID; return this._contentID;
} }
+1
View File
@@ -67,6 +67,7 @@ export interface FrigateRecording {
export const eventSummarySchema = z export const eventSummarySchema = z
.object({ .object({
camera: z.string(), camera: z.string(),
// Days in RFC3339 format.
day: z.string(), day: z.string(),
label: z.string(), label: z.string(),
sub_label: z.string().nullable(), sub_label: z.string().nullable(),
+6 -4
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../../types'; import { CameraConfig, ExtendedHomeAssistant } from '../../types';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import { import {
CameraManagerCameraMetadata, CameraManagerCameraMetadata,
@@ -25,6 +25,7 @@ import {
MediaMetadataQuery, MediaMetadataQuery,
MediaMetadataQueryResultsMap, MediaMetadataQueryResultsMap,
EngineOptions, EngineOptions,
CameraEndpoint,
} from '../types'; } from '../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { EntityRegistryManager } from '../../utils/ha/entity-registry';
@@ -112,10 +113,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return null; return null;
} }
public getMediaDownloadPath( public async getMediaDownloadPath(
_hass: ExtendedHomeAssistant,
_cameraConfig: CameraConfig, _cameraConfig: CameraConfig,
_media: ViewMedia, _media: ViewMedia,
): string | null { ): Promise<CameraEndpoint | null> {
return null; return null;
} }
@@ -179,7 +181,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
supportsRecordings: false, supportsRecordings: false,
supportsSnapshots: false, supportsSnapshots: false,
supportsTimeline: false, supportsTimeline: false,
} };
} }
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null { public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
+25 -22
View File
@@ -1,5 +1,10 @@
import { HomeAssistant } from 'custom-card-helpers'; 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 { allPromises, arrayify, setify } from '../utils/basic.js';
import { import {
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
@@ -34,11 +39,10 @@ import {
MediaMetadataQuery, MediaMetadataQuery,
MediaMetadataQueryResults, MediaMetadataQueryResults,
EngineOptions, EngineOptions,
CameraEndpoint,
} from './types.js'; } from './types.js';
import orderBy from 'lodash-es/orderBy';
import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js'; import { ViewMedia } from '../view/media.js';
import uniqBy from 'lodash-es/uniqBy';
import { CameraManagerEngine } from './engine.js'; import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum'; import sum from 'lodash-es/sum';
import add from 'date-fns/add'; import add from 'date-fns/add';
@@ -50,6 +54,7 @@ import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import cloneDeep from 'lodash-es/cloneDeep'; import cloneDeep from 'lodash-es/cloneDeep';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { sortMedia } from './util.js';
class QueryClassifier { class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
@@ -356,7 +361,7 @@ export class CameraManager {
concreteQueries.push(query as PartialQueryConcreteType<PQT>); concreteQueries.push(query as PartialQueryConcreteType<PQT>);
} }
} }
return concreteQueries; return concreteQueries.length ? concreteQueries : null;
} }
public async getEvents( public async getEvents(
@@ -459,20 +464,31 @@ export class CameraManager {
return null; 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 { return {
queries: extendedQueries, 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 cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media); const engine = this._store.getEngineForMedia(media);
if (!cameraConfig || !engine) { if (!cameraConfig || !engine) {
return null; return null;
} }
return engine.getMediaDownloadPath(cameraConfig, media); return await engine.getMediaDownloadPath(hass, cameraConfig, media);
} }
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
@@ -623,7 +639,7 @@ export class CameraManager {
await Promise.all(_queries.map((query) => processQuery(query))); await Promise.all(_queries.map((query) => processQuery(query)));
const cachedOutputQueries = sum( const cachedOutputQueries = sum(
Array.from(results.values()).map((result) => Number(result.cached)), Array.from(results.values()).map((result) => Number(result.cached ?? 0)),
); );
log( log(
@@ -681,20 +697,7 @@ export class CameraManager {
} }
} }
} }
return this._sortMedia(mediaArray); return 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',
);
} }
public getCameraEndpoints( 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,403 @@
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,
getViewMediaFromBrowseMediaArray,
isMediaWithinDates,
} from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
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) &&
isMediaWithinDates(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 &&
isMediaWithinDates(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;
}
return getViewMediaFromBrowseMediaArray(results.browseMedia);
}
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 }),
};
}
}
+51
View File
@@ -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';
+12
View File
@@ -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>[];
}
+1 -1
View File
@@ -80,7 +80,7 @@ export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
return ( return (
// a starts within the range of b. // a starts within the range of b.
(a.start >= b.start && a.start <= b.end) || (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.end >= b.start && a.end <= b.end) ||
// a encompasses the entire range of b. // a encompasses the entire range of b.
(a.start <= b.start && a.end >= b.end) (a.start <= b.start && a.end >= b.end)
+2
View File
@@ -22,6 +22,7 @@ export enum QueryResultsType {
export enum Engine { export enum Engine {
Frigate = 'frigate', Frigate = 'frigate',
Generic = 'generic', Generic = 'generic',
MotionEye = 'motioneye',
} }
export interface DataQuery { export interface DataQuery {
@@ -110,6 +111,7 @@ export interface CameraManagerMediaCapabilities {
export interface CameraManagerCameraMetadata { export interface CameraManagerCameraMetadata {
title: string; title: string;
icon: string; icon: string;
engineLogo?: string;
} }
export interface CameraEndpointsContext { export interface CameraEndpointsContext {
+16
View File
@@ -4,6 +4,9 @@ import startOfDay from 'date-fns/startOfDay';
import endOfDay from 'date-fns/endOfDay'; import endOfDay from 'date-fns/endOfDay';
import endOfMinute from 'date-fns/endOfMinute'; import endOfMinute from 'date-fns/endOfMinute';
import { DateRange } from './range'; import { DateRange } from './range';
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { ViewMedia } from '../view/media';
export const convertRangeToCacheFriendlyTimes = ( export const convertRangeToCacheFriendlyTimes = (
range: DateRange, range: DateRange,
@@ -37,3 +40,16 @@ export const capEndDate = (end: Date): Date => {
const now = new Date(); const now = new Date();
return end > now ? now : end; 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
View File
@@ -1091,7 +1091,11 @@ class FrigateCard extends LitElement {
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
): Promise<void> { ): Promise<void> {
this._cameraManager = new CameraManager( this._cameraManager = new CameraManager(
new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig), new CameraManagerEngineFactory(
this._entityRegistryManager,
this._resolvedMediaCache,
cardWideConfig,
),
this._cardWideConfig, this._cardWideConfig,
); );
+10 -4
View File
@@ -23,7 +23,7 @@ import {
} from '../utils/media-to-view.js'; } from '../utils/media-to-view.js';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
import { dispatchMessageEvent, renderProgressIndicator } from './message.js'; import { renderMessage, renderProgressIndicator } from './message.js';
import './thumbnail.js'; import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import { createRef, ref, Ref } from 'lit/directives/ref.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) { 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', icon: 'mdi:multimedia',
}); });
} }
const selected = this.view?.queryResults?.getSelectedResult(); const selected = this.view?.queryResults?.getSelectedResult();
return html` return html` <div class="grid">
${this._showLoaderTop ${this._showLoaderTop
? html`${renderProgressIndicator({ ? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig, cardWideConfig: this.cardWideConfig,
@@ -490,7 +496,7 @@ export class FrigateCardGalleryCore extends LitElement {
componentRef: this._refLoaderBottom, componentRef: this._refLoaderBottom,
})}` })}`
: ''} : ''}
`; </div>`;
} }
public updated(changedProps: PropertyValues): void { public updated(changedProps: PropertyValues): void {
+5 -3
View File
@@ -539,8 +539,9 @@ export class FrigateCardLiveCarousel extends LitElement {
<frigate-card-live-provider <frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load} ?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig} .cameraConfig=${cameraConfig}
.cameraEndpoints=${guard([this.cameraManager, cameraID], () => .cameraEndpoints=${guard(
this.cameraManager?.getCameraEndpoints(cameraID), [this.cameraManager, cameraID],
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)} )}
.label=${cameraMetadata?.title ?? ''} .label=${cameraMetadata?.title ?? ''}
.liveConfig=${config} .liveConfig=${config}
@@ -638,6 +639,7 @@ export class FrigateCardLiveCarousel extends LitElement {
.label="${cameraMetadataCurrent .label="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}` ? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}" : ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${config.controls.title} .titlePopupConfig=${config.controls.title}
.selected=${this._getSelectedCameraIndex()} .selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()} transitionEffect=${this._getTransitionEffect()}
@@ -723,7 +725,7 @@ export class FrigateCardLiveProvider
protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef(); protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
public async play(): Promise<void> { public async play(): Promise<void> {
playMediaMutingIfNecessary(this._refProvider.value); playMediaMutingIfNecessary(this, this._refProvider.value);
} }
public pause(): void { public pause(): void {
+4
View File
@@ -115,6 +115,9 @@ export class FrigateCardMediaCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public label?: string; public label?: string;
@property({ attribute: false })
public logo?: string;
@property({ attribute: false }) @property({ attribute: false })
public titlePopupConfig?: TitleControlConfig; public titlePopupConfig?: TitleControlConfig;
@@ -420,6 +423,7 @@ export class FrigateCardMediaCarousel extends LitElement {
${ref(this._titleControlRef)} ${ref(this._titleControlRef)}
.config=${this.titlePopupConfig} .config=${this.titlePopupConfig}
.text="${this.label}" .text="${this.label}"
.logo="${this.logo}"
.fitInto=${this as HTMLElement} .fitInto=${this as HTMLElement}
> >
</frigate-card-title-control> ` </frigate-card-title-control> `
+1 -1
View File
@@ -247,7 +247,7 @@ export class FrigateCardMenu extends LitElement {
</frigate-card-submenu-select>`; </frigate-card-submenu-select>`;
} }
let stateParameters: StateParameters = { ...button }; let stateParameters = { ...button } as StateParameters;
const svgPath = const svgPath =
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : ''; stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.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 { actionHandler } from '../action-handler-directive.js';
import submenuStyle from '../scss/submenu.scss'; import submenuStyle from '../scss/submenu.scss';
import { import {
@@ -39,7 +39,7 @@ export class FrigateCardSubmenu extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item }); const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters);
const getIcon = (stateParameters: StateParameters): TemplateResult => { const getIcon = (stateParameters: StateParameters): TemplateResult => {
if (stateParameters.icon) { if (stateParameters.icon) {
return html` <ha-icon return html` <ha-icon
@@ -95,7 +95,7 @@ export class FrigateCardSubmenu extends LitElement {
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)} @click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
> >
<ha-icon-button <ha-icon-button
style="${styleMap(this.submenu.style || {})}" style="${styleMap(this.submenu.style as StyleInfo || {})}"
class="button" class="button"
slot="trigger" slot="trigger"
.label=${this.submenu.title || ''} .label=${this.submenu.title || ''}
@@ -205,7 +205,7 @@ export class FrigateCardSubmenuSelect extends LitElement {
icon: domainIcon('select'), icon: domainIcon('select'),
// Pull out the dynamic properties (like icon, and title) from the state. // 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. // Override it with anything explicitly set in the submenuSelect.
...this.submenuSelect, ...this.submenuSelect,
+28 -20
View File
@@ -170,11 +170,9 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
const rawEndTime = this.media.getEndTime(); const rawEndTime = this.media.getEndTime();
const endTime = rawStartTime const duration =
? rawEndTime rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
? getDurationString(rawStartTime, rawEndTime) const inProgress = this.media.inProgress() ? localize('event.in_progress') : null;
: localize('event.in_progress')
: null;
const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null; const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null;
const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null; const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null;
@@ -200,13 +198,18 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
></ha-icon> ></ha-icon>
<span title="${startTime}">${startTime}</span> <span title="${startTime}">${startTime}</span>
</div> </div>
<div> ${duration || inProgress
? html` <div>
<ha-icon <ha-icon
title=${localize('event.duration')} title=${localize('event.duration')}
.icon=${'mdi:clock-outline'} .icon=${'mdi:clock-outline'}
></ha-icon> ></ha-icon>
<span title="${endTime}">${endTime}</span> ${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>` </div>`
: ''}`
: ''} : ''}
${this.cameraTitle ${this.cameraTitle
? html` <div> ? html` <div>
@@ -266,11 +269,9 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
const rawEndTime = this.media.getEndTime(); const rawEndTime = this.media.getEndTime();
const endTime = rawStartTime const duration =
? rawEndTime rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
? getDurationString(rawStartTime, rawEndTime) const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null;
: localize('event.in_progress')
: null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
@@ -284,17 +285,24 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
<div class="details"> <div class="details">
${startTime ${startTime
? html` <div> ? html` <div>
<ha-icon title=${localize( <ha-icon
'recording.start', title=${localize('recording.start')}
)} .icon=${'mdi:calendar-clock-outline'}></ha-icon> .icon=${'mdi:calendar-clock-outline'}
></ha-icon>
<span title="${startTime}">${startTime}</span> <span title="${startTime}">${startTime}</span>
</div> </div>
<div> ${duration || inProgress
<ha-icon title=${localize( ? html` <div>
'recording.duration', <ha-icon
)} .icon=${'mdi:clock-outline'}></ha-icon> title=${localize('recording.duration')}
<span title="${endTime}">${endTime}</span> .icon=${'mdi:clock-outline'}
></ha-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>` </div>`
: ''}`
: ''} : ''}
${seek ${seek
? html` <div> ? html` <div>
+12 -10
View File
@@ -525,10 +525,13 @@ export class FrigateCardTimelineCore extends LitElement {
.selectResultIfFound((media) => media.getID() === properties.item); .selectResultIfFound((media) => media.getID() === properties.item);
if (!newResults || !newResults.hasSelectedResult()) { if (!newResults || !newResults.hasSelectedResult()) {
// This can happen if this is a recording query (with recorded hours) // This can happen in a few situations:
// and an event is clicked on the timeline, or if the current thumbnails // - If this is a recording query (with recorded hours) and an event is
// is a filtered view from the media gallery (i.e. any case where the // clicked on the timeline
// thumbnails may not be match the events 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( const fullEventView = await this._createViewWithEventMediaQuery(
this._createEventMediaQuerys(), this._createEventMediaQuerys(),
{ {
@@ -551,10 +554,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
if (view) { if (view) {
view view.dispatchChangeEvent(this);
// If the user is clicking something in the timeline, don't
// subsequently shift the window (it's pretty jarring).
.dispatchChangeEvent(this);
if (this.view?.is('timeline')) { if (this.view?.is('timeline')) {
dispatchFrigateCardEvent(this, 'thumbnails:open'); dispatchFrigateCardEvent(this, 'thumbnails:open');
@@ -895,8 +895,10 @@ export class FrigateCardTimelineCore extends LitElement {
const mediaStartTime = media?.getStartTime(); const mediaStartTime = media?.getStartTime();
const mediaEndTime = media?.getEndTime(); const mediaEndTime = media?.getEndTime();
const mediaWindow: TimelineWindow | null = const mediaWindow: TimelineWindow | null =
media && mediaStartTime && mediaEndTime media && mediaStartTime
? { start: mediaStartTime, end: mediaEndTime } // 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; : null;
const context = this.view.context?.timeline; const context = this.view.context?.timeline;
+5 -2
View File
@@ -1,7 +1,6 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { TitleControlConfig } from '../types.js'; import { TitleControlConfig } from '../types.js';
import titleStyle from '../scss/title-control.scss'; import titleStyle from '../scss/title-control.scss';
@@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public fitInto?: HTMLElement; public fitInto?: HTMLElement;
@property({ attribute: false })
public logo?: string;
protected _toastRef: Ref<PaperToast> = createRef(); protected _toastRef: Ref<PaperToast> = createRef();
/** /**
@@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement {
.text="${this.text}" .text="${this.text}"
.fitInto=${this.fitInto} .fitInto=${this.fitInto}
> >
${this.logo ? html`<img src=${this.logo} />` : ''}
</paper-toast>`; </paper-toast>`;
} }
@@ -86,6 +89,6 @@ export class FrigateCardTitleControl extends LitElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-title-control": FrigateCardTitleControl 'frigate-card-title-control': FrigateCardTitleControl;
} }
} }
+77 -14
View File
@@ -45,7 +45,7 @@ import {
changeViewToRecentEventsForCameraAndDependents, changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view.js'; } 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 { ViewMediaClassifier } from '../view/media-classifier';
import { guard } from 'lit/directives/guard.js'; import { guard } from 'lit/directives/guard.js';
import { localize } from '../localize/localize.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 { canonicalizeHAURL } from '../utils/ha/index.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import { playMediaMutingIfNecessary } from '../utils/media.js'; import { playMediaMutingIfNecessary } from '../utils/media.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/media.js';
export interface MediaViewerViewContext { export interface MediaViewerViewContext {
seek?: Date; seek?: Date;
@@ -399,7 +403,13 @@ export class FrigateCardViewerCarousel extends LitElement {
const media = const media =
this.view?.queryResults?.getSelectedResult() ?? this.view?.queryResults?.getSelectedResult() ??
this.view?.queryResults?.getResult(resultCount - 1); 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; 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 return html` <frigate-card-media-carousel
${ref(this._refMediaCarousel)} ${ref(this._refMediaCarousel)}
.carouselOptions=${guard([this.viewerConfig], () => ({ .carouselOptions=${guard([this.viewerConfig], () => ({
@@ -426,6 +441,7 @@ export class FrigateCardViewerCarousel extends LitElement {
this._getPlugins.bind(this), this._getPlugins.bind(this),
)} )}
.label=${media.getTitle() ?? undefined} .label=${media.getTitle() ?? undefined}
.logo=${cameraMetadata?.engineLogo}
.titlePopupConfig=${this.viewerConfig?.controls.title} .titlePopupConfig=${this.viewerConfig?.controls.title}
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0} .selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
transitionEffect=${this._getTransitionEffect()} transitionEffect=${this._getTransitionEffect()}
@@ -544,30 +560,53 @@ export class FrigateCardViewerProvider
@property({ attribute: false }) @property({ attribute: false })
public cardWideConfig?: CardWideConfig; 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> { public async play(): Promise<void> {
playMediaMutingIfNecessary(this._refVideoProvider.value); playMediaMutingIfNecessary(
this,
this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value,
);
} }
public pause(): void { public pause(): void {
this._refVideoProvider.value?.pause(); (this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause();
} }
public mute(): void { 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 { 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 { 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 { 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;
}
} }
/** /**
@@ -582,10 +621,6 @@ export class FrigateCardViewerProvider
// If this specific media item has no clip, then do nothing (even if all // If this specific media item has no clip, then do nothing (even if all
// the other media items do). // the other media items do).
!ViewMediaClassifier.isEvent(this.media) || !ViewMediaClassifier.isEvent(this.media) ||
// If the event certainly has no clip, don't bother going further. If
// we're not sure for this camera type (i.e. hasClip() === null) the query
// will proceed anyway.
this.media.hasClip() === false ||
!MediaQueriesClassifier.areEventQueries(this.view.query) !MediaQueriesClassifier.areEventQueries(this.view.query)
) { ) {
return; return;
@@ -666,8 +701,9 @@ export class FrigateCardViewerProvider
} }
return ViewMediaClassifier.isVideo(this.media) return ViewMediaClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
? html`<frigate-card-ha-hls-player ? html`<frigate-card-ha-hls-player
${ref(this._refVideoProvider)} ${ref(this._refFrigateCardMediaPlayer)}
allow-exoplayer allow-exoplayer
aria-label="${this.media.getTitle() ?? ''}" aria-label="${this.media.getTitle() ?? ''}"
?autoplay=${false} ?autoplay=${false}
@@ -679,6 +715,33 @@ export class FrigateCardViewerProvider
.hass=${this.hass} .hass=${this.hass}
> >
</frigate-card-ha-hls-player>` </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 : html`<img
aria-label="${this.media.getTitle() ?? ''}" aria-label="${this.media.getTitle() ?? ''}"
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}" src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
+13 -2
View File
@@ -11,17 +11,28 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS = export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
`${CONF_CAMERAS}.#.frigate.labels` as const; `${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_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_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM = export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
`${CONF_CAMERAS}.#.go2rtc.stream` as const; `${CONF_CAMERAS}.#.go2rtc.stream` as const;
export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` 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_ID = `${CONF_CAMERAS}.#.id` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS = export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
`${CONF_CAMERAS}.#.image.refresh_seconds` as const; `${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_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_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 = export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
`${CONF_CAMERAS}.#.webrtc_card.entity` as const; `${CONF_CAMERAS}.#.webrtc_card.entity` as const;
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
+1
View File
@@ -1,4 +1,5 @@
declare module '*.scss'; declare module '*.scss';
declare module '*.svg';
declare module '*.jpg'; declare module '*.jpg';
declare module 'view' { declare module 'view' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
+49 -1
View File
@@ -28,6 +28,11 @@ import {
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
CONF_CAMERAS_ARRAY_IMAGE_URL, CONF_CAMERAS_ARRAY_IMAGE_URL,
CONF_CAMERAS_ARRAY_LIVE_PROVIDER, 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_TITLE,
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
@@ -159,6 +164,10 @@ import {
sideLoadHomeAssistantElements, sideLoadHomeAssistantElements,
} from './utils/ha'; } from './utils/ha';
import { setLowPerformanceProfile } from './performance.js'; 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_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS = 'cameras';
@@ -166,6 +175,7 @@ const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc'; const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
const MENU_CAMERAS_IMAGE = 'cameras.image'; const MENU_CAMERAS_IMAGE = 'cameras.image';
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card'; const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider'; const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
@@ -888,6 +898,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
icon: { icon: {
name?: string; name?: string;
path?: string; path?: string;
viewBox?: string;
}, },
template: TemplateResult, template: TemplateResult,
): TemplateResult { ): TemplateResult {
@@ -907,7 +918,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${icon.name ${icon.name
? html` <ha-icon .icon=${icon.name}></ha-icon> ` ? html` <ha-icon .icon=${icon.name}></ha-icon> `
: icon.path : 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> <span>${localize(labelPath)}</span>
</div> </div>
@@ -1411,6 +1424,41 @@ 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( ${this._putInSubmenu(
+13
View File
@@ -57,6 +57,18 @@
"image": "Home Assistant images", "image": "Home Assistant images",
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" "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)", "title": "Title for this camera (Autodetected from entity)",
"triggers": { "triggers": {
"entities": "Trigger from other entities", "entities": "Trigger from other entities",
@@ -436,6 +448,7 @@
"recording": { "recording": {
"camera": "Camera", "camera": "Camera",
"duration": "Duration", "duration": "Duration",
"in_progress": "In Progress",
"events": "Events", "events": "Events",
"seek": "Seek", "seek": "Seek",
"start": "Start" "start": "Start"
+13
View File
@@ -57,6 +57,18 @@
"image": "", "image": "",
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" "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à)", "title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
"triggers": { "triggers": {
"entities": "Trigger da altre entità", "entities": "Trigger da altre entità",
@@ -427,6 +439,7 @@
"camera": "", "camera": "",
"duration": "", "duration": "",
"events": "Eventi", "events": "Eventi",
"in_progress": "In corso",
"seek": "Cercare", "seek": "Cercare",
"start": "" "start": ""
}, },
+13
View File
@@ -57,6 +57,18 @@
"image": "", "image": "",
"webrtc-card": "Cartão WebRTC (de @AlexxIT)" "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)", "title": "Título para esta câmera (detectado automaticamente pela entidade)",
"triggers": { "triggers": {
"entities": "Acionar a partir de outras entidades", "entities": "Acionar a partir de outras entidades",
@@ -427,6 +439,7 @@
"camera": "", "camera": "",
"duration": "", "duration": "",
"events": "Eventos", "events": "Eventos",
"in_progress": "Em andamento",
"seek": "Procurar", "seek": "Procurar",
"start": "" "start": ""
}, },
-9
View File
@@ -28,14 +28,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
const computeMJPEGStreamUrl = (entity: CameraEntity): string => const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`; `/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_HLS = 'hls';
const STREAM_TYPE_WEB_RTC = 'web_rtc'; const STREAM_TYPE_WEB_RTC = 'web_rtc';
@@ -100,7 +92,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
.src=${typeof this._connected == 'undefined' || this._connected .src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj) ? computeMJPEGStreamUrl(this.stateObj)
: ''} : ''}
.alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
/> />
`; `;
} }
+1 -1
View File
@@ -57,7 +57,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
} }
public isMuted(): boolean { public isMuted(): boolean {
return this._video?.muted() ?? true; return this._video?.muted ?? true;
} }
public seek(seconds: number): void { public seek(seconds: number): void {
+4 -1
View File
@@ -1,6 +1,7 @@
:host { :host {
width: 100%; width: 100%;
height: auto; height: 100%;
display: block;
overflow: auto; overflow: auto;
// Hide scrollbar: IE and Edge // Hide scrollbar: IE and Edge
@@ -11,7 +12,9 @@
--frigate-card-gallery-gap: 3px; --frigate-card-gallery-gap: 3px;
--frigate-card-gallery-columns: 4; --frigate-card-gallery-columns: 4;
}
.grid {
display: grid; display: grid;
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr)); grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
grid-auto-rows: min-content; grid-auto-rows: min-content;
+3 -1
View File
@@ -1,8 +1,10 @@
@use 'dotdotdot.scss'; @use 'dotdotdot.scss';
:host { :host {
min-height: 100%; display: block;
height: 100%;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
+6
View File
@@ -1,6 +1,12 @@
:host { :host {
display: block; display: block;
overflow: hidden; overflow: hidden;
aspect-ratio: 1 / 1;
display: flex;
justify-content: center;
align-items: center;
} }
img { img {
+7
View File
@@ -6,4 +6,11 @@
paper-toast { paper-toast {
max-width: unset; max-width: unset;
min-width: unset; min-width: unset;
display: flex;
align-items: center;
}
paper-toast img {
max-height: 24px;
padding-left: 10px;
} }
+1
View File
@@ -7,6 +7,7 @@
} }
img, img,
video,
frigate-card-ha-hls-player { frigate-card-ha-hls-player {
display: block; display: block;
width: 100%; width: 100%;
+51 -19
View File
@@ -1,7 +1,6 @@
import { import {
CallServiceActionConfig, CallServiceActionConfig,
ConfirmationRestrictionConfig, ConfirmationRestrictionConfig,
CustomActionConfig,
HomeAssistant, HomeAssistant,
LovelaceCardConfig, LovelaceCardConfig,
MoreInfoActionConfig, MoreInfoActionConfig,
@@ -95,7 +94,7 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
export type AutoPlayCondition = (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 { export class FrigateCardError extends Error {
context?: unknown; context?: unknown;
@@ -182,15 +181,13 @@ const moreInfoActionSchema = schemaForType<
action: z.literal('more-info'), action: z.literal('more-info'),
}), }),
); );
const customActionSchema = schemaForType<
CustomActionConfig & ExtendedConfirmationRestrictionConfig const customActionSchema = actionBaseSchema
>()(
actionBaseSchema
.extend({ .extend({
action: z.literal('fire-dom-event'), action: z.literal('fire-dom-event'),
}) })
.passthrough(), .passthrough();
);
const noActionSchema = schemaForType< const noActionSchema = schemaForType<
NoActionConfig & ExtendedConfirmationRestrictionConfig NoActionConfig & ExtendedConfirmationRestrictionConfig
>()( >()(
@@ -450,19 +447,29 @@ const jsmpegConfigSchema = z.object({
* Camera configuration section * Camera configuration section
*/ */
const cameraConfigDefault = { const cameraConfigDefault = {
live_provider: 'auto' as const,
engine: 'auto' as const,
frigate: {
client_id: 'frigate' as const,
},
dependencies: { dependencies: {
all_cameras: false, all_cameras: false,
cameras: [], cameras: [],
}, },
engine: 'auto' as const,
frigate: {
client_id: 'frigate' as const,
},
hide: false,
image: { image: {
refresh_seconds: 1, 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: { triggers: {
motion: false, motion: false,
occupancy: true, occupancy: true,
@@ -505,7 +512,6 @@ const cameraConfigSchema = z
engine: z.enum(ENGINES).default('auto'), engine: z.enum(ENGINES).default('auto'),
frigate: z frigate: z
.object({ .object({
// No URL validation to allow relative URLs within HA (e.g. Frigate addon).
url: z.string().optional(), url: z.string().optional(),
client_id: z.string().default(cameraConfigDefault.frigate.client_id), client_id: z.string().default(cameraConfigDefault.frigate.client_id),
camera_name: z.string().optional(), camera_name: z.string().optional(),
@@ -513,6 +519,35 @@ const cameraConfigSchema = z
zones: z.string().array().optional(), zones: z.string().array().optional(),
}) })
.default(cameraConfigDefault.frigate), .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 options.
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), 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 // Card ID (used for query string commands). Restrict contents to only values
// that be easily used in a URL. // that be easily used in a URL.
card_id: z card_id: z.string().regex(/^\w+$/).optional(),
.string()
.regex(/^\w+$/)
.optional(),
// Stock lovelace card config. // Stock lovelace card config.
type: z.string(), type: z.string(),
+4
View File
@@ -196,3 +196,7 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
export const sleep = async (seconds: number) => { export const sleep = async (seconds: number) => {
await new Promise((r) => setTimeout(r, seconds * 1000)); await new Promise((r) => setTimeout(r, seconds * 1000));
}; };
export const isValidDate = (date: Date): boolean => {
return !isNaN(date.getTime());
}
+15 -6
View File
@@ -10,14 +10,16 @@ export const downloadMedia = async (
cameraManager: CameraManager, cameraManager: CameraManager,
media: ViewMedia, media: ViewMedia,
): Promise<void> => { ): Promise<void> => {
const path = cameraManager.getMediaDownloadPath(media); const download = await cameraManager.getMediaDownloadPath(hass, media);
if (!path) { if (!download) {
throw new FrigateCardError(localize('error.download_no_media')); throw new FrigateCardError(localize('error.download_no_media'));
} }
let finalURL = download.endpoint;
if (download.sign) {
let response: string | null | undefined; let response: string | null | undefined;
try { try {
response = await homeAssistantSignPath(hass, path); response = await homeAssistantSignPath(hass, download.endpoint);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
} }
@@ -25,8 +27,15 @@ export const downloadMedia = async (
if (!response) { if (!response) {
throw new FrigateCardError(localize('error.download_sign_failed')); throw new FrigateCardError(localize('error.download_sign_failed'));
} }
finalURL = response;
}
// 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 ( if (
!isSameOrigin ||
navigator.userAgent.startsWith('Home Assistant/') || navigator.userAgent.startsWith('Home Assistant/') ||
navigator.userAgent.startsWith('HomeAssistant/') navigator.userAgent.startsWith('HomeAssistant/')
) { ) {
@@ -36,13 +45,13 @@ export const downloadMedia = async (
// User-agents are specified here: // 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 // - 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 // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
window.open(response, '_blank'); window.open(finalURL, '_blank');
} else { } else {
// Use the HTML5 download attribute to prevent a new window from // Use the HTML5 download attribute to prevent a new window from
// temporarily opening. // temporarily opening.
const link = document.createElement('a'); const link = document.createElement('a');
link.setAttribute('download', ''); link.setAttribute('download', 'download');
link.href = response; link.href = finalURL;
link.click(); link.click();
link.remove(); link.remove();
} }
+6 -7
View File
@@ -11,10 +11,10 @@ export const getEndpointAddressOrDispatchError = async (
endpoint: CameraEndpoint, endpoint: CameraEndpoint,
expires?: number, expires?: number,
): Promise<string | null> => { ): Promise<string | null> => {
let address: string | null;
if (!endpoint.sign) { if (!endpoint.sign) {
address = endpoint.endpoint; return endpoint.endpoint;
} else { }
let response: string | null | undefined; let response: string | null | undefined;
try { try {
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires); response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
@@ -22,12 +22,11 @@ export const getEndpointAddressOrDispatchError = async (
errorToConsole(e as Error); errorToConsole(e as Error);
return null; return null;
} }
address = response ? response.replace(/^http/i, 'ws') : null;
}
if (!address) { if (!response) {
dispatchErrorMessageEvent(element, localize('error.failed_sign')); dispatchErrorMessageEvent(element, localize('error.failed_sign'));
return null; 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;
}
}
+41
View File
@@ -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;
+1
View File
@@ -2,6 +2,7 @@ import { z } from 'zod';
export const entitySchema = z.object({ export const entitySchema = z.object({
config_entry_id: z.string().nullable(), config_entry_id: z.string().nullable(),
device_id: z.string().nullable(),
disabled_by: z.string().nullable(), disabled_by: z.string().nullable(),
entity_id: z.string(), entity_id: z.string(),
hidden_by: z.string().nullable(), hidden_by: z.string().nullable(),
+4 -3
View File
@@ -354,12 +354,13 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
* location will be the Chromecast receiver, not HA). * location will be the Chromecast receiver, not HA).
* @param url The media URL * @param url The media URL
*/ */
export const canonicalizeHAURL = ( export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
export function canonicalizeHAURL(
hass: ExtendedHomeAssistant, hass: ExtendedHomeAssistant,
url?: string, url?: string,
): string | null => { ): string | null {
if (hass && url && url.startsWith('/')) { if (hass && url && url.startsWith('/')) {
return hass.hassUrl(url); return hass.hassUrl(url);
} }
return url ?? null; return url ?? null;
}; }
+8 -6
View File
@@ -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 ( export const playMediaMutingIfNecessary = async (
player?: FrigateCardMediaPlayer, player: FrigateCardMediaPlayer,
video?: HTMLVideoElement | FrigateCardMediaPlayer,
): Promise<void> => { ): Promise<void> => {
// If the play call fails, and the media is not already muted, mute it first // 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 // and then try again. This works around some browsers that prevent
// auto-play unless the video is muted. // auto-play unless the video is muted.
if (player?.play) { if (video?.play) {
player.play().catch((ev) => { video.play().catch((ev) => {
if (ev.name === 'NotAllowedError' && !player.isMuted()) { if (ev.name === 'NotAllowedError' && !player.isMuted()) {
player.mute(); player.mute();
player.play().catch(); video.play().catch();
} }
}); });
} }
+9 -7
View File
@@ -2,6 +2,11 @@ import { Task } from '@lit-labs/task';
import { ReactiveControllerHost } from '@lit/reactive-element'; import { ReactiveControllerHost } from '@lit/reactive-element';
import { HomeAssistant } from 'custom-card-helpers'; 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. * Fetch a thumbnail URL and return a data URL.
* @param hass Home Assistant object. * @param hass Home Assistant object.
@@ -12,10 +17,10 @@ const fetchThumbnail = async (
hass: HomeAssistant, hass: HomeAssistant,
thumbnailURL: string, thumbnailURL: string,
): Promise<string | null> => { ): Promise<string | null> => {
if (!hass) { if (!hass || !thumbnailURL) {
return null; return null;
} }
if (thumbnailURL?.startsWith('data:')) { if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) {
return thumbnailURL; return thumbnailURL;
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -57,9 +62,7 @@ export const createFetchThumbnailTask = (
getThumbnailURL: () => string | undefined, getThumbnailURL: () => string | undefined,
autoRun = true, autoRun = true,
): Task<FetchThumbnailTaskArgs, string | null> => { ): Task<FetchThumbnailTaskArgs, string | null> => {
return new Task( return new Task(host, {
host,
{
// Do not re-run the task if hass changes, unless it was previously undefined. // Do not re-run the task if hass changes, unless it was previously undefined.
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()], args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise< task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
@@ -72,6 +75,5 @@ export const createFetchThumbnailTask = (
return fetchThumbnail(hass, thumbnailURL); return fetchThumbnail(hass, thumbnailURL);
}, },
autoRun: autoRun, autoRun: autoRun,
}, });
);
}; };
+11 -1
View File
@@ -1,5 +1,10 @@
export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
export enum VideoContentType {
MP4 = "mp4",
HLS = "hls",
}
export class ViewMedia { export class ViewMedia {
protected _mediaType: ViewMediaType; protected _mediaType: ViewMediaType;
protected _cameraID: string; protected _cameraID: string;
@@ -17,6 +22,9 @@ export class ViewMedia {
public getMediaType(): ViewMediaType { public getMediaType(): ViewMediaType {
return this._mediaType; return this._mediaType;
} }
public getVideoContentType(): VideoContentType | null {
return null;
}
public getID(): string | null { public getID(): string | null {
return null; return null;
} }
@@ -26,6 +34,9 @@ export class ViewMedia {
public getEndTime(): Date | null { public getEndTime(): Date | null {
return null; return null;
} }
public inProgress(): boolean | null {
return null;
}
public getContentID(): string | null { public getContentID(): string | null {
return null; return null;
} }
@@ -60,7 +71,6 @@ export interface EventViewMedia extends ViewMedia {
getWhat(): string[] | null; getWhat(): string[] | null;
getTags(): string[] | null; getTags(): string[] | null;
isGroupableWith(that: EventViewMedia): boolean; isGroupableWith(that: EventViewMedia): boolean;
hasClip(): boolean | null;
} }
export interface RecordingViewMedia extends ViewMedia { export interface RecordingViewMedia extends ViewMedia {
+35 -16
View File
@@ -689,15 +689,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@rollup/plugin-image@npm:^2.1.1": "@rollup/plugin-image@npm:^3.0.2":
version: 2.1.1 version: 3.0.2
resolution: "@rollup/plugin-image@npm:2.1.1" resolution: "@rollup/plugin-image@npm:3.0.2"
dependencies: dependencies:
"@rollup/pluginutils": ^3.1.0 "@rollup/pluginutils": ^5.0.1
mini-svg-data-uri: ^1.2.3 mini-svg-data-uri: ^1.4.4
peerDependencies: peerDependencies:
rollup: ^1.20.0 || ^2.0.0 rollup: ^1.20.0||^2.0.0||^3.0.0
checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6 peerDependenciesMeta:
rollup:
optional: true
checksum: f9d8f587f10c51398fa8c23f1543e3073f969cf7e4acd7f401e02a3e3752702a9eb289ddb14009733ce37d04474c549aba9e7d13ebf50e26226266ba51546b69
languageName: node languageName: node
linkType: hard linkType: hard
@@ -763,6 +766,22 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0":
version: 2.22.2 version: 2.22.2
resolution: "@stencil/core@npm:2.22.2" resolution: "@stencil/core@npm:2.22.2"
@@ -814,7 +833,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/estree@npm:*": "@types/estree@npm:*, @types/estree@npm:^1.0.0":
version: 1.0.0 version: 1.0.0
resolution: "@types/estree@npm:1.0.0" resolution: "@types/estree@npm:1.0.0"
checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443 checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443
@@ -2251,7 +2270,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"estree-walker@npm:^2.0.1": "estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2":
version: 2.0.2 version: 2.0.2
resolution: "estree-walker@npm:2.0.2" resolution: "estree-walker@npm:2.0.2"
checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc
@@ -2418,7 +2437,7 @@ __metadata:
"@lit-labs/task": ^1.1.3 "@lit-labs/task": ^1.1.3
"@rollup/plugin-babel": ^5.3.1 "@rollup/plugin-babel": ^5.3.1
"@rollup/plugin-commonjs": ^22.0.2 "@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-json": ^4.1.0
"@rollup/plugin-node-resolve": ^13.3.0 "@rollup/plugin-node-resolve": ^13.3.0
"@rollup/plugin-replace": ^4.0.0 "@rollup/plugin-replace": ^4.0.0
@@ -2464,7 +2483,7 @@ __metadata:
vis-util: ^5.0.2 vis-util: ^5.0.2
web-dialog: ^0.0.11 web-dialog: ^0.0.11
xss: ^1.0.14 xss: ^1.0.14
zod: ^3.20.6 zod: ^3.21.4
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -3504,7 +3523,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mini-svg-data-uri@npm:^1.2.3": "mini-svg-data-uri@npm:^1.4.4":
version: 1.4.4 version: 1.4.4
resolution: "mini-svg-data-uri@npm:1.4.4" resolution: "mini-svg-data-uri@npm:1.4.4"
bin: bin:
@@ -5483,9 +5502,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"zod@npm:^3.20.6": "zod@npm:^3.21.4":
version: 3.20.6 version: 3.21.4
resolution: "zod@npm:3.20.6" resolution: "zod@npm:3.21.4"
checksum: 804b1934b8b5e2fa3750bec90043e8118b201f330b9957b8b768389a971acadf812d2060cf62921086512dab4af691d10490acb03333da58fc485c0791893c89 checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
languageName: node languageName: node
linkType: hard linkType: hard