feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+1
View File
@@ -6,6 +6,7 @@
- [`conditions`](conditions.md)
- [`dimensions`](dimensions.md)
- [`elements`](elements/README.md)
- [`folders`](folders.md)
- [`image`](image.md)
- [`live`](live.md)
- [`media_gallery`](media-gallery.md)
+1
View File
@@ -9,6 +9,7 @@
- [`conditions`](../conditions.md)
- [`dimensions`](../dimensions.md)
- [`elements`](../elements/README.md)
- [`folders`](../folders.md)
- [`image`](../image.md)
- [`live`](../live.md)
- [`media_gallery`](../media-gallery.md)
@@ -112,6 +112,20 @@ action: custom:advanced-camera-card-action
advanced_camera_card_action: expand
```
## `folder`
Show a given folder in the folder gallery.
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: folder
# [...]
```
| Parameter | Default | Description |
| --------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `folder` | The first configured folder (under [`folders`](../../folders.md)). | An optional id of the folder to show, see the `id` parameter under [`folders` configuration](../../folders.md). |
## `fullscreen`
Toggle fullscreen.
@@ -9,6 +9,7 @@
- [`conditions`](../../conditions.md)
- [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md)
- [`folders`](../../folders.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
@@ -9,6 +9,7 @@
- [`conditions`](../../conditions.md)
- [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md)
- [`folders`](../../folders.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
+1
View File
@@ -7,6 +7,7 @@
- [`engine`](engine.md)
- [`conditions`](../conditions.md)
- [`dimensions`](../dimensions.md)
- [`folders`](../folders.md)
- [`elements`](../elements/README.md)
- [`image`](../image.md)
- [`live`](../live.md)
@@ -1 +1 @@
!> This functionality is experimental. It may be broken, slow or change without warning.
!> This functionality is experimental. It may be broken, slow or change without warning or major version number change.
+1
View File
@@ -8,6 +8,7 @@
- [`elements`](README.md)
- [Custom Elements](./custom/README.md)
- [Stock Elements](./stock/README.md)
- [`folders`](../folders.md)
- [`image`](../image.md)
- [`live`](../live.md)
- [`media_gallery`](../media-gallery.md)
@@ -8,6 +8,7 @@
- [`elements`](../../elements/README.md)
- [Custom Actions](README.md)
- [Stock Actions](../stock/README.md)
- [`folders`](../../folders.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
@@ -8,6 +8,7 @@
- [`elements`](../../elements/README.md)
- [Custom Actions](../custom/README.md)
- [Stock Actions](README.md)
- [`folders`](../../folders.md)
- [`image`](../../image.md)
- [`live`](../../live.md)
- [`media_gallery`](../../media-gallery.md)
+91
View File
@@ -0,0 +1,91 @@
# `folders`
[](./common/experimental-warning.md ':include')
The `folders` stanza is used for configuring folders from which media/subfolders may be viewed.
?> To configure the behavior of the gallery in which folders are displayed, see the [`media_gallery` configuration](./media-gallery.md).
```yaml
folders:
# [...]
```
| Option | Default | Description |
| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | | An optional folder `id` which can be used by the [`folder` action](./actions/custom/README.md?id=folder) to show a particular folder contents. |
| `ha` | | Options for `ha` folder types. See below. |
| `icon` | | An optional folder icon. |
| `title` | | An optional folder title. |
| `type` | `ha` | The type of folder, `ha` for Home Assistant media folders (currently the only supported type of folder). |
## `ha`
Used to specify a Home Assistant media folder.
```yaml
folders:
- type: ha
ha:
# [...]
```
| Option | Default | Description |
| ------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | | An optional Home Assistant `Media` browser URL to use as the query base. If `path` is also specified, those matchers are applied against folders "below" the folder specified in `url`. |
| `path` | [`{ id: media-source:// }`] | An optional array of matchers to dynamically compare against the Home Assistant media folder hierarchy. See below. |
?> `url` is never fetched, nor sent over the network. It is only processed
locally in your browser. The host part of the URL can optionally be removed.
### `path`
An array of matchers to navigate "down" a folder hierarchy. If `url` is also
specified, matchers are applied starting at that folder, otherwise they are
applied at the media source root (i.e. `media-source://`).
```yaml
folders:
- type: ha
ha:
path:
# [...]
```
| Option | Default | Description |
| ---------- | ------- | ------------------------------------------------------ |
| `id` | | An optional media source `id` to match against. |
| `title` | | An optional title name to match against. |
| `title_re` | | An optional title regular expression to match against. |
?> Specifying multiple `path` matchers (other than `id`) requires a query at
each level of the folder hierarchy and is slower than directly specifying the
media source `id` (if known) or the `url` of the folder.
#### Examples
See [Folder Examples](../examples.md?id=folders).
#### Understanding Media Source IDs and "parent folders"
Home Assistant Media Source IDs are typically long integration-specific non-user
friendly strings that refer to a media item, or folder of media items. Media
source "folders" do not have an intrinsic parent as with filesystem folders,
rather a trail is built as the user navigates "downwards" -- but anything could
theoretically be the parent of anything.
## Fully expanded reference
[](common/expanded-warning.md ':include')
```yaml
folders:
- type: ha
ha:
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
path:
- id: 'media-source://'
- title: 'Frigate'
- title_re: 'Clips.*'
- title_re: 'Person.*'
```
+9 -8
View File
@@ -1,16 +1,17 @@
# `media_gallery`
The `media_gallery` is used for providing an overview of all `clips`, `snapshots` and `recordings` in a thumbnail gallery.
The `media_gallery` is used for providing an overview of all `clips`,
`snapshots`, `recordings` and `folder` contents in a thumbnail gallery.
```yaml
media_gallery:
# [...]
```
| Option | Default | Description |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `snapshots`, `recordings`). |
| `controls` | | Configuration for the Media viewer controls. See below. |
| Option | Default | Description |
| ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `folder`, `snapshots`, `recordings`). |
| `controls` | | Configuration for the Media Gallery controls. See below. |
## `controls`
@@ -25,9 +26,9 @@ media_gallery:
# [...]
```
| Option | Default | Description |
| ------ | ------- | ----------------------------------------------------------------------------------------------------- |
| `mode` | `right` | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. |
| Option | Default | Description |
| ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `right` | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. The `folder` view does not support media filtering. |
### `thumbnails`
+53 -47
View File
@@ -36,6 +36,7 @@ menu:
| `display_mode` | The `display_mode` button allows changing between single and grid views. |
| `download` | The `download` menu button: allow direct download of the media being displayed. |
| `expand` | The `expand` menu button: expand the card into a popup/dialog. |
| `folders` | The `folders` menu button to select a folder of media to view in the [`media_gallery`](./media-gallery.md). Will only appear if [`folders`](./folders.md) are configured. |
| `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. Please note that fullscreen behavior on iPhone is limited, see [troubleshooting](../troubleshooting.md?id=fullscreen-doesn39t-work-on-iphone). |
| `image` | The `image` view menu button: brings the user to the static `image` view. |
| `iris` | The main Advanced Camera Card `iris` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . |
@@ -80,66 +81,56 @@ This card supports several menu styles.
menu:
alignment: left
buttons:
iris:
priority: 50
enabled: true
alignment: matching
icon: iris
cameras:
priority: 50
enabled: true
alignment: matching
icon: mdi:video-switch
substreams:
priority: 50
enabled: true
alignment: matching
icon: mdi:video-input-component
live:
priority: 50
enabled: true
alignment: matching
icon: mdi:cctv
clips:
priority: 50
enabled: true
alignment: matching
icon: mdi:filmstrip
snapshots:
priority: 50
enabled: true
alignment: matching
icon: mdi:camera
image:
priority: 50
enabled: false
alignment: matching
icon: mdi:image
timeline:
priority: 50
enabled: true
alignment: matching
icon: mdi:chart-gantt
download:
priority: 50
enabled: true
alignment: matching
icon: mdi:download
camera_ui:
priority: 50
enabled: true
alignment: matching
icon: mdi:web
fullscreen:
cameras:
priority: 50
enabled: true
alignment: matching
icon: mdi:fullscreen
icon: mdi:video-switch
clips:
priority: 50
enabled: true
alignment: matching
icon: mdi:filmstrip
download:
priority: 50
enabled: true
alignment: matching
icon: mdi:download
expand:
priority: 50
enabled: true
alignment: matching
icon: mdi:arrow-expand-all
folders:
priority: 50
enabled: true
alignment: matching
icon: mdi:folder-multiple
fullscreen:
priority: 50
enabled: true
alignment: matching
icon: mdi:fullscreen
image:
priority: 50
enabled: false
alignment: matching
icon: mdi:image
iris:
priority: 50
enabled: true
alignment: matching
icon: iris
live:
priority: 50
enabled: true
alignment: matching
icon: mdi:cctv
media_player:
priority: 50
enabled: false
@@ -171,6 +162,21 @@ menu:
enabled: true
alignment: matching
icon: mdi:home
snapshots:
priority: 50
enabled: true
alignment: matching
icon: mdi:camera
substreams:
priority: 50
enabled: true
alignment: matching
icon: mdi:video-input-component
timeline:
priority: 50
enabled: true
alignment: matching
icon: mdi:chart-gantt
button_size: 40
position: top
style: hidden
+1
View File
@@ -142,6 +142,7 @@ This card supports several different views.
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clip` | Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon. |
| `clips` | Shows a gallery of clips for this camera. |
| `folder` | Shows a gallery of media from a [`folder`](./folders.md). |
| `image` | Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.interaction_seconds`). |
| `live` | Shows the live camera view with the configured [live provider](./cameras/live-provider.md). |
| `recording` | Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon. |
+1 -3
View File
@@ -31,7 +31,7 @@ Post `v6.0.0`, all releases are automated with ([semantic-release](https://githu
Releases follow [Semantic Versioning](https://semver.org/) with the following definitions:
- **MAJOR** version changes for any backwards incompatible changes. This means any change that would _require_ users to update their card config, regardless of whether that update is automated or manual.
- **MAJOR** version changes for any backwards incompatible changes (excluding functionality marked as experimental). This means any change that would _require_ users to update their card config, regardless of whether that update is automated or manual.
- **MINOR** version changes for any functionality added in a backwards compatible manner. This may mean new features or behavioral changes that do not require a card update.
- **PATCH** version changes for backward compatible bug fixes
@@ -46,8 +46,6 @@ Releases follow [Semantic Versioning](https://semver.org/) with the following de
## Translations
[![translation badge](https://badge.inlang.com/?url=github.com/dermotduffy/advanced-camera-card)](https://fink.inlang.com/github.com/dermotduffy/advanced-camera-card?ref=badge)
To add translations, you can manually edit the JSON translation files in
`src/localize/languages` or use the [inlang](https://inlang.com/) online editor.
+73
View File
@@ -336,6 +336,79 @@ cameras:
all_cameras: true
```
## Folders
These examples create folders that can be viewed in the
[`media_gallery`](./configuration/media-gallery.md).
### Home Assistant default root
This example creates a folder at the Home Assistant media root.
```yaml
type: custom:advanced-camera-card
cameras:
- camera_entity: camera.office
folders:
- type: ha
```
### Folder within the Home Assistant default root
This example applies a title match against the Home Assistant media root folder
looking for a folder entitled `Frigate`. The resulting media will be the
contents of that folder (if found).
```yaml
type: custom:advanced-camera-card
cameras:
- camera_entity: camera.office
folders:
- type: ha
ha:
path:
- title: 'Frigate'
```
### Folder URLs
This example uses the `url` parameter to establish the root of the query. Within
that folder, it looks for a sub-folder that matches the regular expression
`Clips.*`, and within that looks for a folder that matches the regular
expression `Person.*`. The resulting media will be the contents of that folder
(if found).
```yaml
type: custom:advanced-camera-card
cameras:
- camera_entity: camera.office
folders:
- type: ha
ha:
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
path:
- title_re: 'Clips.*'
- title_re: 'Person.*'
```
### Folder Paths
This example starts with the `media-source://frigate` folder, and looks for a
precisely titled `Clips [my-instance]` folder within that. The resulting media
will be the contents of that folder (if found).
```yaml
type: custom:advanced-camera-card
cameras:
- camera_entity: camera.office
folders:
- type: ha
ha:
path:
- id: 'media-source://frigate'
- title: 'Clips [my-instance]'
```
## Human interaction
This example will automatically use a HD live substream when
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'eslint/config';
import tsParser from '@typescript-eslint/parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([
{
extends: compat.extends('plugin:@typescript-eslint/recommended', 'prettier'),
languageOptions: {
parser: tsParser,
ecmaVersion: 2020,
sourceType: 'module',
},
rules: {},
},
]);
+5 -5
View File
@@ -69,12 +69,12 @@
"@types/js-yaml": "^4",
"@types/lodash-es": "^4.17.12",
"@types/masonry-layout": "^4.2.8",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"@typescript-eslint/eslint-plugin": "^8.30.1",
"@typescript-eslint/parser": "^8.30.1",
"@vitest/coverage-istanbul": "^1.6.0",
"conventional-changelog-conventionalcommits": "^8.0.0",
"docsify-cli": "^4.4.4",
"eslint": "^8.57.0",
"eslint": "^9.24.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
@@ -91,8 +91,8 @@
"semantic-release": "^24.1.1",
"semantic-release-export-data": "^1.1.0",
"ts-prune": "^0.10.3",
"type-fest": "^4.20.0",
"typescript": "^5.4.5",
"type-fest": "^4.41.0",
"typescript": "^5.8.3",
"vitest": "^1.6.0",
"vitest-mock-extended": "^1.3.1"
},
+1 -1
View File
@@ -189,7 +189,7 @@ export const actionHandler = directive(
return noChange;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render(_options?: AdvancedCameraCardActionHandlerOptions) {}
},
);
+52
View File
@@ -0,0 +1,52 @@
import { CacheInterface } from './types.js';
export class CacheBase<Key, Value> implements CacheInterface<Key, Value> {
private _cache: Map<Key, Value>;
constructor(cache: Map<Key, Value>) {
this._cache = cache;
}
/**
* Determine if the cache has a given id.
* @param key
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(key: Key): boolean {
return this._cache.has(key);
}
public entries(): MapIterator<[Key, Value]> {
return this._cache.entries();
}
public delete(key: Key): boolean {
return this._cache.delete(key);
}
public clear(): void {
this._cache.clear();
}
/**
* Get resolved media information given an id.
* @param key The id.
* @returns The `ResolvedMedia` for this id.
*/
public get(key: Key): Value | null {
return this._cache.get(key) ?? null;
}
public getMatches(predicate: (arg: Value) => boolean): Value[] {
return [...this._cache.values()].filter(predicate);
}
/**
* Add a given ResolvedMedia to the cache.
* @param key The id for the object.
* @param resolvedMedia The `ResolvedMedia` object.
*/
public set(key: Key, val: Value): void {
this._cache.set(key, val);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { CacheBase } from './base';
export class Cache<Key, Value> extends CacheBase<Key, Value> {
constructor() {
super(new Map());
}
}
+8
View File
@@ -0,0 +1,8 @@
import { CacheBase } from './base';
import { EqualityMap } from './equality-map';
export class EqualityCache<Key, Value> extends CacheBase<Key, Value> {
constructor() {
super(new EqualityMap());
}
}
+80
View File
@@ -0,0 +1,80 @@
import { isEqual } from 'lodash-es';
interface EqualityMapItem<Key, Value> {
key: Key;
value: Value;
}
/** A simple equality based map. This is not performant and should be used for
* small datasets only.
*/
export class EqualityMap<Key, Value> implements Map<Key, Value> {
private _data: EqualityMapItem<Key, Value>[] = [];
get [Symbol.toStringTag](): string {
return 'EqualityMap';
}
public has(key: Key): boolean {
return !!this.get(key);
}
public get(searchKey: Key): Value | undefined {
for (const pair of this._data) {
if (isEqual(pair.key, searchKey)) {
return pair.value;
}
}
return undefined;
}
public set(key: Key, value: Value): this {
this.delete(key);
this._data.push({ key, value });
return this;
}
public delete(searchKey: Key): boolean {
for (let i = 0; i < this._data.length; i++) {
if (isEqual(this._data[i].key, searchKey)) {
this._data.splice(i, 1);
return true;
}
}
return false;
}
public clear(): void {
this._data = [];
}
public *entries(): MapIterator<[Key, Value]> {
for (const pair of this._data) {
yield [pair.key, pair.value];
}
}
public forEach(
callbackfn: (value: Value, key: Key, map: Map<Key, Value>) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thisArg?: any,
): void {
for (const pair of this._data) {
callbackfn.call(thisArg, pair.value, pair.key, this);
}
}
public get size(): number {
return this._data.length;
}
public [Symbol.iterator](): IterableIterator<[Key, Value]> {
return this.entries();
}
public keys(): IterableIterator<Key> {
return this._data.map((pair) => pair.key).values();
}
public values(): IterableIterator<Value> {
return this._data.map((pair) => pair.value).values();
}
}
+70
View File
@@ -0,0 +1,70 @@
import { CacheInterface } from './types';
import { EqualityCache } from './equality-cache';
interface ExpiringValue<Value> {
value: Value;
expires?: Date;
}
export class ExpiringEqualityCache<Key, Value> implements CacheInterface<Key, Value> {
protected _data: EqualityCache<Key, ExpiringValue<Value>> = new EqualityCache();
public get(key: Key): Value | null {
const value = this._data.get(key);
const now = new Date();
return value && (!value.expires || now <= value.expires) ? value.value : null;
}
public has(key: Key): boolean {
return !!this.get(key);
}
public set(key: Key, value: Value, expiry?: Date): void {
this._data.set(key, {
value: value,
expires: expiry,
});
// Clean up old requests on set.
this._expireOldValues();
}
public delete(key: Key): boolean {
return this._data.delete(key);
}
public clear(): void {
this._data.clear();
}
public *entries(): MapIterator<[Key, Value]> {
const now = new Date();
for (const [key, value] of this._data.entries()) {
if (!value.expires || now <= value.expires) {
yield [key, value.value];
}
}
}
public getMatches(predicate: (value: Value) => boolean): Value[] {
const out: Value[] = [];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_key, value] of this.entries()) {
if (predicate(value)) {
out.push(value);
}
}
return out;
}
protected _expireOldValues(): void {
const now = new Date();
for (const [key, value] of this._data.entries()) {
if (value.expires && now > value.expires) {
this._data.delete(key);
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import QuickLRU from 'quick-lru';
import { CacheBase } from './base';
export class LRUCache<Key, Value> extends CacheBase<Key, Value> {
constructor(maxSize: number) {
super(new QuickLRU({ maxSize }));
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface CacheInterface<K, V> {
has(k: K): boolean;
get(k: K): V | null;
set(k: K, v: V): void;
delete(k: K): boolean;
clear(): void;
entries(): MapIterator<[K, V]>;
getMatches(predicate: (arg: V) => boolean): V[];
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera';
import { CameraInitializationError } from '../error';
@@ -1,21 +1,21 @@
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../ha/browse-media/types';
import { BrowseMediaWalker } from '../../ha/browse-media/walker';
import { getMediaDownloadPath } from '../../ha/download';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { canonicalizeHAURL } from '../../utils/ha';
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache';
import { Endpoint } from '../../types';
import { ViewMedia } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { CameraManagerEngine } from '../engine';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEventCallback,
CameraManagerMediaCapabilities,
DataQuery,
CameraManagerRequestCache,
CameraQuery,
EventQuery,
PartialEventQuery,
QueryType,
@@ -28,22 +28,22 @@ export class BrowseMediaCameraManagerEngine
extends GenericCameraManagerEngine
implements CameraManagerEngine
{
protected _browseMediaManager: BrowseMediaManager;
protected _browseMediaWalker: BrowseMediaWalker;
protected _entityRegistryManager: EntityRegistryManager;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _requestCache: RequestCache;
protected _requestCache: CameraManagerRequestCache;
public constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
browseMediaManager: BrowseMediaManager,
browseMediaManager: BrowseMediaWalker,
resolvedMediaCache: ResolvedMediaCache,
requestCache: RequestCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._browseMediaManager = browseMediaManager;
this._browseMediaWalker = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
this._requestCache = requestCache;
}
@@ -66,18 +66,11 @@ export class BrowseMediaCameraManagerEngine
hass: HomeAssistant,
_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;
): Promise<Endpoint | null> {
return getMediaDownloadPath(hass, media.getContentID(), this._resolvedMediaCache);
}
public getQueryResultMaxAge(query: DataQuery): number | null {
public getQueryResultMaxAge(query: CameraQuery): number | null {
if (query.type === QueryType.Event) {
return BROWSE_MEDIA_CACHE_SECONDS;
}
@@ -85,7 +78,7 @@ export class BrowseMediaCameraManagerEngine
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities {
return {
canFavorite: false,
canDownload: true,
-85
View File
@@ -1,85 +0,0 @@
import { format } from 'date-fns';
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 this._browseMedia._metadata?.endDate ?? 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 this._browseMedia._metadata?.what ?? 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 {
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
}
}
-6
View File
@@ -1,6 +0,0 @@
export interface BrowseMediaMetadata {
cameraID: string;
startDate: Date;
endDate: Date;
what?: string[];
}
@@ -1,49 +0,0 @@
import {
RichBrowseMedia,
MEDIA_CLASS_VIDEO,
MEDIA_CLASS_IMAGE,
} from '../../../utils/ha/browse-media/types';
import { ViewMedia } from '../../../view/media';
import { BrowseMediaViewMediaFactory } from '../media';
import { BrowseMediaMetadata } from '../types';
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,
);
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()];
};
+2 -65
View File
@@ -1,69 +1,6 @@
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import sortedUniqBy from 'lodash-es/sortedUniqBy';
import { orderBy, sortedUniqBy } from 'lodash-es';
import { DateRange, MemoryRangeSet } from './range';
import { DataQuery, QueryResults, RecordingSegment } from './types';
interface RequestCacheItem<Request, Response> {
request: Request;
response: Response;
expires?: Date;
}
interface CameraManagerCache<Request, Response> {
get(request: Request): Response | null;
has(request: Request): boolean;
set(request: Request, response: Response, expiry?: Date): void;
}
export class MemoryRequestCache<Request, Response>
implements CameraManagerCache<Request, Response>
{
protected _data: RequestCacheItem<Request, Response>[] = [];
public get(request: Request): Response | null {
const now = new Date();
for (const item of this._data) {
if (
(!item.expires || now <= item.expires) &&
this._contains(request, item.request)
) {
return item.response;
}
}
return null;
}
public clear(): void {
this._data = [];
}
public has(request: Request): boolean {
return !!this.get(request);
}
public set(request: Request, response: Response, expiry?: Date): void {
this._data.push({
request: request,
response: response,
expires: expiry,
});
// Clean up old requests on set.
this._expireOldRequests();
}
protected _contains(a: Request, b: Request): boolean {
return isEqual(a, b);
}
protected _expireOldRequests(): void {
const now = new Date();
this._data = this._data.filter((item) => !item.expires || now < item.expires);
}
}
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
import { RecordingSegment } from './types';
class MemoryRangedCache<Data> {
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
+2 -1
View File
@@ -2,8 +2,9 @@ import { ActionsExecutor } from '../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras';
import { isTriggeredState } from '../ha/is-triggered-state';
import { HassStateDifference } from '../ha/types';
import { localize } from '../localize/localize';
import { HassStateDifference, isTriggeredState } from '../utils/ha';
import { Capabilities } from './capabilities';
import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
+10 -10
View File
@@ -1,14 +1,14 @@
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { CameraConfig } from '../config/schema/cameras';
import { BrowseMediaWalker } from '../ha/browse-media/walker';
import { EntityRegistryManager } from '../ha/registry/entity/types';
import { ResolvedMediaCache } from '../ha/resolved-media';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { RecordingSegmentsCache, RequestCache } from './cache';
import { RecordingSegmentsCache } from './cache';
import { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error';
import { CameraEventCallback, Engine } from './types';
import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types';
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
interface CameraManagerEngineFactoryOptions {
@@ -44,7 +44,7 @@ export class CameraManagerEngineFactory {
this._entityRegistryManager,
options.stateWatcher,
new RecordingSegmentsCache(),
new RequestCache(),
new CameraManagerRequestCache(),
options.eventCallback,
);
break;
@@ -55,9 +55,9 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new MotionEyeCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
new BrowseMediaManager(),
new BrowseMediaWalker(),
options.resolvedMediaCache,
new RequestCache(),
new CameraManagerRequestCache(),
options.eventCallback,
);
break;
@@ -66,9 +66,9 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new ReolinkCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
new BrowseMediaManager(),
new BrowseMediaWalker(),
options.resolvedMediaCache,
new RequestCache(),
new CameraManagerRequestCache(),
options.eventCallback,
);
}
+7 -7
View File
@@ -1,15 +1,15 @@
import { CameraConfig } from '../config/schema/cameras';
import { HomeAssistant } from '../ha/types';
import { ViewMedia } from '../view/media';
import { Endpoint } from '../types';
import { ViewMedia } from '../view/item';
import { ViewItemCapabilities } from '../view/types';
import { Camera } from './camera';
import { CameraManagerReadOnlyConfigStore } from './store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -90,7 +90,7 @@ export interface CameraManagerEngine {
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null>;
): Promise<Endpoint | null>;
favoriteMedia(
hass: HomeAssistant,
@@ -99,7 +99,7 @@ export interface CameraManagerEngine {
favorite: boolean,
): Promise<void>;
getQueryResultMaxAge(query: DataQuery): number | null;
getQueryResultMaxAge(query: CameraQuery): number | null;
getMediaSeekTime(
hass: HomeAssistant,
@@ -121,7 +121,7 @@ export interface CameraManagerEngine {
cameraConfig: CameraConfig,
): CameraManagerCameraMetadata;
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null;
getCameraEndpoints(
cameraConfig: CameraConfig,
+2 -2
View File
@@ -1,13 +1,13 @@
import uniq from 'lodash-es/uniq';
import { uniq } from 'lodash-es';
import { ActionsExecutor } from '../../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras';
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine';
+27 -28
View File
@@ -1,22 +1,21 @@
import { add, endOfHour, format, fromUnixTime, startOfHour } from 'date-fns';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniqWith from 'lodash-es/uniqWith';
import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import {
allPromises,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
} from '../../utils/basic';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache';
import { ViewMedia, ViewMediaType } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
import { RecordingSegmentsCache } from '../cache';
import { Camera } from '../camera';
import {
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
@@ -26,13 +25,12 @@ import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { DateRange } from '../range';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraManagerRequestCache,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -112,7 +110,7 @@ export class FrigateCameraManagerEngine
protected _entityRegistryManager: EntityRegistryManager;
protected _frigateEventWatcher: FrigateEventWatcher;
protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: RequestCache;
protected _requestCache: CameraManagerRequestCache;
// Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle(
@@ -125,7 +123,7 @@ export class FrigateCameraManagerEngine
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventCallback);
@@ -158,13 +156,13 @@ export class FrigateCameraManagerEngine
_hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
): Promise<Endpoint | null> {
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/notifications/${media.getID()}/` +
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`${ViewItemClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`,
sign: true,
};
@@ -541,7 +539,7 @@ export class FrigateCameraManagerEngine
protected _getCameraIDMatch(
store: CameraManagerReadOnlyConfigStore,
query: DataQuery,
query: CameraQuery,
instanceID: string,
cameraName: string,
): string | null {
@@ -588,17 +586,17 @@ export class FrigateCameraManagerEngine
if (!cameraConfig) {
continue;
}
let mediaType: 'clip' | 'snapshot' | null = null;
let mediaType: ViewMediaType | null = null;
if (
!query.hasClip &&
!query.hasSnapshot &&
(event.has_clip || event.has_snapshot)
) {
mediaType = event.has_clip ? 'clip' : 'snapshot';
mediaType = event.has_clip ? ViewMediaType.Clip : ViewMediaType.Snapshot;
} else if (query.hasSnapshot && event.has_snapshot) {
mediaType = 'snapshot';
mediaType = ViewMediaType.Snapshot;
} else if (query.hasClip && event.has_clip) {
mediaType = 'clip';
mediaType = ViewMediaType.Clip;
}
if (!mediaType) {
continue;
@@ -646,7 +644,7 @@ export class FrigateCameraManagerEngine
return output;
}
public getQueryResultMaxAge(query: DataQuery): number | null {
public getQueryResultMaxAge(query: CameraQuery): number | null {
if (query.type === QueryType.Event) {
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (query.type === QueryType.Recording) {
@@ -664,11 +662,12 @@ export class FrigateCameraManagerEngine
): Promise<number | null> {
const start = media.getStartTime();
const end = media.getEndTime();
if (!start || !end || target < start || target > end) {
const cameraID = media.getCameraID();
if (!start || !end || target < start || target > end || !cameraID) {
return null;
}
const cameraID = media.getCameraID();
const query: RecordingSegmentsQuery = {
cameraIDs: new Set([cameraID]),
start: start,
@@ -898,9 +897,9 @@ export class FrigateCameraManagerEngine
return seekMilliseconds / 1000;
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
public getMediaCapabilities(media: ViewMedia): ViewItemCapabilities {
return {
canFavorite: ViewMediaClassifier.isEvent(media),
canFavorite: ViewItemClassifier.isEvent(media),
canDownload: true,
};
}
@@ -926,7 +925,7 @@ export class FrigateCameraManagerEngine
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
const getUIEndpoint = (): Endpoint | null => {
if (!cameraConfig.frigate.url) {
return null;
}
@@ -976,7 +975,7 @@ export class FrigateCameraManagerEngine
};
};
const getJSMPEG = (): CameraEndpoint | null => {
const getJSMPEG = (): Endpoint | null => {
return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` +
@@ -53,6 +53,7 @@ export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInter
let json: unknown;
try {
json = JSON.parse(data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.warn('Received non-JSON payload as Frigate event', data);
return;
@@ -1,4 +1,4 @@
import { ViewMedia } from '../../view/media';
import { ViewMedia } from '../../view/item';
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
export class FrigateViewMediaClassifier {
+6 -8
View File
@@ -1,5 +1,5 @@
import { fromUnixTime } from 'date-fns';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import {
EventViewMedia,
@@ -7,7 +7,7 @@ import {
VideoContentType,
ViewMedia,
ViewMediaType,
} from '../../view/media';
} from '../../view/item';
import { FrigateEvent, FrigateRecording } from './types';
import {
getEventMediaContentID,
@@ -36,7 +36,7 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
// sublabels (`_splitSubLabels` in engine-frigate.ts).
subLabels?: string[],
) {
super(mediaType, cameraID);
super(mediaType, { cameraID });
this._event = event;
this._contentID = contentID;
this._thumbnail = thumbnail;
@@ -88,8 +88,6 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
public getTags(): string[] | null {
return this._subLabels;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public isGroupableWith(that: EventViewMedia): boolean {
return (
this.getMediaType() === that.getMediaType() &&
@@ -113,7 +111,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
contentID: string,
title: string,
) {
super(mediaType, cameraID);
super(mediaType, { cameraID });
this._recording = recording;
this._id = id;
this._contentID = contentID;
@@ -150,7 +148,7 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
export class FrigateViewMediaFactory {
static createEventViewMedia(
mediaType: 'clip' | 'snapshot',
mediaType: ViewMediaType,
cameraID: string,
cameraConfig: CameraConfig,
event: FrigateEvent,
@@ -191,7 +189,7 @@ export class FrigateViewMediaFactory {
}
return new FrigateRecordingViewMedia(
'recording',
ViewMediaType.Recording,
cameraID,
recording,
getRecordingID(cameraConfig, recording),
+1 -1
View File
@@ -1,7 +1,7 @@
import { HomeAssistant } from '../../ha/types';
import { homeAssistantWSRequest } from '../../ha/ws-request';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
import { homeAssistantWSRequest } from '../../utils/ha/ws-request';
import { RecordingSegment } from '../types';
import {
EventSummary,
+9 -10
View File
@@ -1,23 +1,22 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { HomeAssistant } from '../../ha/types';
import { getEntityTitle } from '../../utils/ha';
import { ViewMedia } from '../../view/media';
import { Endpoint } from '../../types';
import { ViewMedia } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -155,7 +154,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
): Promise<CameraEndpoint | null> {
): Promise<Endpoint | null> {
return null;
}
@@ -168,7 +167,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return;
}
public getQueryResultMaxAge(_query: DataQuery): number | null {
public getQueryResultMaxAge(_query: CameraQuery): number | null {
return null;
}
@@ -211,7 +210,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
};
}
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities | null {
return null;
}
@@ -219,7 +218,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
cameraConfig: CameraConfig,
_context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getWebRTCCard = (): CameraEndpoint | null => {
const getWebRTCCard = (): Endpoint | null => {
// The user may override this in their webrtc_card configuration.
const endpoint = cameraConfig.camera_entity ? cameraConfig.camera_entity : null;
return endpoint ? { endpoint: endpoint } : null;
+37 -32
View File
@@ -1,12 +1,13 @@
import { add } from 'date-fns';
import cloneDeep from 'lodash-es/cloneDeep';
import sum from 'lodash-es/sum';
import { cloneDeep, sum } from 'lodash-es';
import PQueue from 'p-queue';
import { CardCameraAPI } from '../card-controller/types.js';
import { sortItems } from '../card-controller/view/sort.js';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz.js';
import { CameraConfig, CamerasConfig } from '../config/schema/cameras.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { localize } from '../localize/localize.js';
import { Endpoint } from '../types.js';
import {
allPromises,
arrayify,
@@ -16,19 +17,19 @@ import {
} from '../utils/basic.js';
import { getCameraID } from '../utils/camera.js';
import { log } from '../utils/debug.js';
import { ViewMedia } from '../view/media.js';
import { ViewItemClassifier } from '../view/item-classifier.js';
import { ViewItem, ViewMedia } from '../view/item.js';
import { ViewItemCapabilities } from '../view/types.js';
import { Capabilities } from './capabilities.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { CameraManagerEngine } from './engine.js';
import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
CameraQuery,
Engine,
EngineOptions,
EventQuery,
@@ -38,7 +39,7 @@ import {
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaQuery,
PartialDataQuery,
PartialCameraQuery,
PartialEventQuery,
PartialQueryConcreteType,
PartialRecordingQuery,
@@ -55,24 +56,25 @@ import {
RecordingSegmentsQueryResultsMap,
ResultsMap,
} from './types.js';
import { sortMedia } from './utils/sort-media.js';
export class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
export class CameraQueryClassifier {
public static isEventQuery(
query: CameraQuery | PartialCameraQuery,
): query is EventQuery {
return query.type === QueryType.Event;
}
public static isRecordingQuery(
query: DataQuery | PartialDataQuery,
query: CameraQuery | PartialCameraQuery,
): query is RecordingQuery {
return query.type === QueryType.Recording;
}
public static isRecordingSegmentsQuery(
query: DataQuery | PartialDataQuery,
query: CameraQuery | PartialCameraQuery,
): query is RecordingSegmentsQuery {
return query.type === QueryType.RecordingSegments;
}
public static isMediaMetadataQuery(
query: DataQuery | PartialDataQuery,
query: CameraQuery | PartialCameraQuery,
): query is MediaMetadataQuery {
return query.type === QueryType.MediaMetadata;
}
@@ -103,7 +105,7 @@ export class QueryResultClassifier {
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewMedia[];
results: ViewItem[];
}
export class CameraManager {
@@ -318,7 +320,7 @@ export class CameraManager {
});
}
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
cameraIDs: string | Set<string>,
partialQuery: PQT,
): PartialQueryConcreteType<PQT>[] | null {
@@ -330,17 +332,17 @@ export class CameraManager {
}
for (const [engine, cameraIDs] of engines) {
let queries: DataQuery[] | null = null;
let queries: CameraQuery[] | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (QueryClassifier.isEventQuery(partialQuery)) {
if (CameraQueryClassifier.isEventQuery(partialQuery)) {
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
} else if (CameraQueryClassifier.isRecordingQuery(partialQuery)) {
queries = engine.generateDefaultRecordingQuery(
this._store,
cameraIDs,
partialQuery,
);
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
queries = engine.generateDefaultRecordingSegmentsQuery(
this._store,
cameraIDs,
@@ -426,7 +428,7 @@ export class CameraManager {
public async extendMediaQueries<T extends MediaQuery>(
queries: T[],
results: ViewMedia[],
results: ViewItem[],
direction: 'earlier' | 'later',
engineOptions?: EngineOptions,
): Promise<ExtendedMediaQueryResult<T> | null> {
@@ -438,6 +440,9 @@ export class CameraManager {
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
let output: Date | null = null;
for (const result of results) {
if (!ViewItemClassifier.isMedia(result)) {
continue;
}
const startTime = result.getStartTime();
if (
startTime &&
@@ -495,7 +500,7 @@ export class CameraManager {
return null;
}
const outputMedia = sortMedia(results.concat(newChunkMedia));
const outputMedia = sortItems(results.concat(newChunkMedia));
// If the media did not _ACTUALLY_ get longer, there is no new media despite
// the increased limit, so just return null.
@@ -509,7 +514,7 @@ export class CameraManager {
};
}
public async getMediaDownloadPath(media: ViewMedia): Promise<CameraEndpoint | null> {
public async getMediaDownloadPath(media: ViewMedia): Promise<Endpoint | null> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
@@ -520,7 +525,7 @@ export class CameraManager {
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
public getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null {
const engine = this._store.getEngineForMedia(media);
if (!engine) {
return null;
@@ -603,7 +608,7 @@ export class CameraManager {
);
}
protected async _handleQuery<QT extends DataQuery>(
protected async _handleQuery<QT extends CameraQuery>(
query: QT | QT[],
engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> {
@@ -623,28 +628,28 @@ export class CameraManager {
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (QueryClassifier.isEventQuery(query)) {
if (CameraQueryClassifier.isEventQuery(query)) {
engineResult = (await engine.getEvents(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingQuery(query)) {
} else if (CameraQueryClassifier.isRecordingQuery(query)) {
engineResult = (await engine.getRecordings(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(query)) {
engineResult = (await engine.getRecordingSegments(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isMediaMetadataQuery(query)) {
} else if (CameraQueryClassifier.isMediaMetadataQuery(query)) {
engineResult = (await engine.getMediaMetadata(
hass,
this._store,
@@ -697,7 +702,7 @@ export class CameraManager {
return results;
}
protected _convertQueryResultsToMedia<QT extends DataQuery>(
protected _convertQueryResultsToMedia<QT extends CameraQuery>(
results: ResultsMap<QT>,
): ViewMedia[] {
const mediaArray: ViewMedia[] = [];
@@ -714,12 +719,12 @@ export class CameraManager {
let media: ViewMedia[] | null = null;
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (
QueryClassifier.isEventQuery(query) &&
CameraQueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
) {
media = engine.generateMediaFromEvents(hass, this._store, query, result);
} else if (
QueryClassifier.isRecordingQuery(query) &&
CameraQueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQueryResult(result)
) {
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
@@ -729,7 +734,7 @@ export class CameraManager {
}
}
}
return sortMedia(mediaArray);
return sortItems(mediaArray);
}
public getCameraEndpoints(
@@ -1,32 +1,29 @@
import { add, endOfDay, parse, startOfDay } from 'date-fns';
import orderBy from 'lodash-es/orderBy';
import { orderBy } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import {
BrowseMediaStep,
BrowseMediaTarget,
} from '../../utils/ha/browse-media/browse-media-manager';
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
BrowseMediaCache,
BrowseMediaMetadata,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { ViewMedia } from '../../view/media';
} from '../../ha/browse-media/types';
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCamera } from '../browse-media/camera';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
import { MemoryRequestCache } from '../cache';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
@@ -67,8 +64,8 @@ const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
protected _directoryCache = new BrowseMediaCache<BrowseMediaMetadata>();
protected _fileCache = new BrowseMediaCache<BrowseMediaMetadata>();
public getEngineType(): Engine {
return Engine.MotionEye;
@@ -224,7 +221,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
};
// For motionEye snapshots and clips are mutually exclusive.
return await this._browseMediaManager.walkBrowseMedias(
return await this._browseMediaWalker.walk(
hass,
[
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
@@ -291,7 +288,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
);
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
const media = await this._browseMediaManager.walkBrowseMedias(
const media = await this._browseMediaWalker.walk(
hass,
[
{
@@ -431,7 +428,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
const getUIEndpoint = (): Endpoint | null => {
return cameraConfig.motioneye?.url
? {
endpoint: cameraConfig.motioneye.url,
+1 -2
View File
@@ -1,5 +1,4 @@
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from '../browse-media/types';
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/types';
import { Engine, EventQueryResults } from '../types';
// ================================
+1 -1
View File
@@ -1,4 +1,4 @@
import orderBy from 'lodash-es/orderBy';
import { orderBy } from 'lodash-es';
interface Range<T extends Date | number> {
start: T;
+1 -1
View File
@@ -1,10 +1,10 @@
import { ActionsExecutor } from '../../card-controller/actions/types';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { createSelectOptionAction } from '../../utils/action.js';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { BrowseMediaCamera } from '../browse-media/camera';
import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities';
+17 -16
View File
@@ -1,26 +1,26 @@
import { add, endOfDay, parse, startOfDay } from 'date-fns';
import { orderBy } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { sortMediaByStartDate } from '../../utils/ha/browse-media/browse-media-manager';
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
import { sortMediaByStartDate } from '../../ha/browse-media/sort-browse-media-by-start-date';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
BrowseMediaCache,
BrowseMediaMetadata,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { ViewMedia } from '../../view/media';
} from '../../ha/browse-media/types';
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
import { MemoryRequestCache } from '../cache';
import { Camera } from '../camera';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
@@ -48,7 +48,8 @@ export class ReolinkQueryResultsClassifier {
}
export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
protected _cache = new MemoryRequestCache<string, BrowseMedia>();
protected _camerasCache = new BrowseMediaCache<BrowseMediaReolinkCameraMetadata>();
protected _cache = new BrowseMediaCache<BrowseMediaMetadata>();
public getEngineType(): Engine {
return Engine.Reolink;
@@ -172,7 +173,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
// that match the expected camera. Some Reolink cameras will not show up
// here causing errors.
// https://github.com/dermotduffy/advanced-camera-card/issues/1723
const camerasWithMedia = await this._browseMediaManager.walkBrowseMedias(
const camerasWithMedia = await this._browseMediaWalker.walk(
hass,
[
{
@@ -188,7 +189,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
},
],
{
...(engineOptions?.useCache !== false && { cache: this._cache }),
...(engineOptions?.useCache !== false && { cache: this._camerasCache }),
},
);
@@ -196,7 +197,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
return null;
}
return await this._browseMediaManager.walkBrowseMedias(
return await this._browseMediaWalker.walk(
hass,
[
{
@@ -263,7 +264,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
let media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
if (directories?.length) {
media = await this._browseMediaManager.walkBrowseMedias(
media = await this._browseMediaWalker.walk(
hass,
[
{
@@ -395,7 +396,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
const getUIEndpoint = (): Endpoint | null => {
return cameraConfig.reolink?.url
? {
endpoint: cameraConfig.reolink.url,
+1 -2
View File
@@ -1,5 +1,4 @@
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from '../browse-media/types';
import { BrowseMediaMetadata, RichBrowseMedia } from '../../ha/browse-media/types';
import { Engine, EventQueryResults } from '../types';
export interface BrowseMediaReolinkCameraMetadata {
+5 -3
View File
@@ -1,7 +1,7 @@
import { CameraConfig } from '../config/schema/cameras';
import { CapabilityKey } from '../types';
import { allPromises } from '../utils/basic';
import { ViewMedia } from '../view/media';
import { ViewMedia } from '../view/item';
import { Camera } from './camera';
import { CameraManagerEngine } from './engine';
import { CapabilitySearchOptions, Engine } from './types';
@@ -133,7 +133,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
}
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
return this.getCameraConfig(media.getCameraID());
const cameraID = media.getCameraID();
return cameraID ? this.getCameraConfig(cameraID) : null;
}
public getEngineOfType(engine: Engine): CameraManagerEngine | null {
@@ -163,7 +164,8 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
}
public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null {
return this.getEngineForCameraID(media.getCameraID());
const cameraID = media.getCameraID();
return cameraID ? this.getEngineForCameraID(cameraID) : null;
}
/**
+17 -21
View File
@@ -1,7 +1,8 @@
import { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { CapabilityKey, Icon } from '../types';
import { ViewMedia } from '../view/media';
import { CapabilityKey, Endpoint, Icon } from '../types';
import { ViewMedia } from '../view/item';
// ====
// Base
@@ -28,11 +29,11 @@ export enum Engine {
Reolink = 'reolink',
}
export interface DataQuery {
export interface CameraQuery {
type: QueryType;
cameraIDs: Set<string>;
}
export type PartialDataQuery = Partial<DataQuery>;
export type PartialCameraQuery = Partial<CameraQuery>;
interface TimeBasedDataQuery {
start: Date;
@@ -44,7 +45,7 @@ interface LimitedDataQuery {
}
export interface MediaQuery
extends DataQuery,
extends CameraQuery,
Partial<TimeBasedDataQuery>,
Partial<LimitedDataQuery> {
favorite?: boolean;
@@ -100,11 +101,6 @@ interface CapabilitySearchAllAny {
}
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
export interface CameraManagerMediaCapabilities {
canFavorite: boolean;
canDownload: boolean;
}
export interface CameraManagerCameraMetadata {
title: string;
icon: Icon;
@@ -118,16 +114,11 @@ export interface CameraEndpointsContext {
view?: AdvancedCameraCardView;
}
export interface CameraEndpoint {
endpoint: string;
sign?: boolean;
}
export interface CameraEndpoints {
ui?: CameraEndpoint;
go2rtc?: CameraEndpoint;
jsmpeg?: CameraEndpoint;
webrtcCard?: CameraEndpoint;
ui?: Endpoint;
go2rtc?: Endpoint;
jsmpeg?: Endpoint;
webrtcCard?: Endpoint;
}
export interface CameraProxyConfig {
@@ -157,6 +148,11 @@ export interface CameraEvent {
}
export type CameraEventCallback = (ev: CameraEvent) => void;
export class CameraManagerRequestCache extends ExpiringEqualityCache<
CameraQuery,
QueryResults
> {}
// ===========
// Event Query
// ===========
@@ -202,7 +198,7 @@ export interface RecordingQueryResults extends QueryResults {
// Recording Segments Query
// ========================
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
export interface RecordingSegmentsQuery extends CameraQuery, TimeBasedDataQuery {
type: QueryType.RecordingSegments;
}
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
@@ -216,7 +212,7 @@ export interface RecordingSegmentsQueryResults extends QueryResults {
// Media metadata Query
// ====================
export interface MediaMetadataQuery extends DataQuery {
export interface MediaMetadataQuery extends CameraQuery {
type: QueryType.MediaMetadata;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { CameraConfig } from '../../config/schema/cameras';
import { CameraEndpoint } from '../types';
import { Endpoint } from '../../types';
export const getDefaultGo2RTCEndpoint = (
cameraConfig: CameraConfig,
@@ -7,7 +7,7 @@ export const getDefaultGo2RTCEndpoint = (
url?: string;
stream?: string;
},
): CameraEndpoint | null => {
): Endpoint | null => {
const url = options?.url ?? cameraConfig.go2rtc?.url;
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
-16
View File
@@ -1,16 +0,0 @@
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { ViewMedia } from '../../view/media';
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() ?? media.getID(),
'asc',
);
};
@@ -10,7 +10,7 @@ import {
getActionConfigGivenAction,
isAdvancedCameraCardCustomAction,
} from '../../utils/action.js';
import { allPromises } from '../../utils/basic.js';
import { allPromises, errorToConsole } from '../../utils/basic.js';
import { TemplateRenderer } from '../templates/index.js';
import { CardActionsManagerAPI } from '../types.js';
import { ActionSet } from './actions/set.js';
@@ -53,7 +53,7 @@ export class ActionsManager implements ActionsExecutor {
let specificActions: Actions | undefined = undefined;
if (view?.is('live')) {
specificActions = config?.live.actions;
} else if (view?.isGalleryView()) {
} else if (view?.isMediaGalleryView()) {
specificActions = config?.media_gallery?.actions;
} else if (view?.isViewerView()) {
specificActions = config?.media_viewer.actions;
@@ -150,6 +150,7 @@ export class ActionsManager implements ActionsExecutor {
await actionSet.execute(this._api);
forwardHaptic('success');
} catch (e) {
errorToConsole(e as Error);
forwardHaptic('warning');
}
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
@@ -6,6 +6,9 @@ export class DownloadAction extends AdvancedCameraCardAction<GeneralActionConfig
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getDownloadManager().downloadViewerMedia();
const item = api.getViewManager().getView()?.queryResults?.getSelectedResult();
if (item) {
await api.getViewItemManager().download(item);
}
}
}
@@ -0,0 +1,27 @@
import { FolderActionConfig } from '../../../config/schema/actions/custom/folder';
import { FolderViewQuery } from '../../../view/query';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class FolderAction extends AdvancedCameraCardAction<FolderActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const folder = api.getFoldersManager().getFolder(this._action.folder);
if (!folder) {
return;
}
const query = api.getFoldersManager().generateDefaultFolderQuery(folder);
if (!query) {
return;
}
await api.getViewManager().setViewByParametersWithExistingQuery({
params: {
view: 'folder',
query: new FolderViewQuery(query),
},
});
}
}
@@ -1,5 +1,6 @@
import { MediaPlayerActionConfig } from '../../../config/schema/actions/custom/media-player';
import { getStreamCameraID } from '../../../utils/substream';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
@@ -9,15 +10,19 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
const mediaPlayer = this._action.media_player;
const mediaPlayerController = api.getMediaPlayerManager();
const view = api.getViewManager().getView();
const media = view?.queryResults?.getSelectedResult() ?? null;
if (this._action.media_player_action === 'stop') {
await mediaPlayerController.stop(mediaPlayer);
} else if (view?.is('live')) {
return;
}
const view = api.getViewManager().getView();
const item = view?.queryResults?.getSelectedResult() ?? null;
if (view?.is('live')) {
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
} else if (view?.isViewerView() && media) {
await mediaPlayerController.playMedia(mediaPlayer, media);
} else if (view?.isViewerView() && item && ViewItemClassifier.isMedia(item)) {
await mediaPlayerController.playMedia(mediaPlayer, item);
}
}
}
@@ -1,4 +1,4 @@
import clamp from 'lodash-es/clamp';
import { clamp } from 'lodash-es';
import {
PartialZoomSettings,
ZOOM_DEFAULT_PAN_X,
+12 -6
View File
@@ -86,12 +86,15 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
const singleStep = async (): Promise<void> => {
this._action.ptz_action &&
(await api
/* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */
if (this._action.ptz_action) {
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
}));
});
}
if (!this._stopped) {
// Only start the timer for the next step after this step returns, and
@@ -121,13 +124,16 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
});
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
this._action.ptz_action &&
(await api
/* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */
if (this._action.ptz_action) {
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'stop',
}));
});
}
});
}
}
@@ -1,4 +1,6 @@
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import { downloadURL } from '../../../utils/download';
import { generateScreenshotFilename } from '../../../utils/screenshot';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
@@ -6,6 +8,13 @@ export class ScreenshotAction extends AdvancedCameraCardAction<GeneralActionConf
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getDownloadManager().downloadScreenshot();
const url = await api
.getMediaLoadedInfoManager()
.get()
?.mediaPlayerController?.getScreenshotURL();
if (url) {
downloadURL(url, generateScreenshotFilename(api.getViewManager().getView()));
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { SleepActionConfig } from '../../../config/schema/actions/custom/sleep';
import { sleep } from '../../../utils/basic';
import { sleep } from '../../../utils/sleep';
import { CardActionsAPI } from '../../types';
import { timeDeltaToSeconds } from '../utils/time-delta';
import { AdvancedCameraCardAction } from './base';
@@ -3,7 +3,6 @@ import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
+3
View File
@@ -10,6 +10,7 @@ import { DefaultAction } from './actions/default';
import { DisplayModeSelectAction } from './actions/display-mode-select';
import { DownloadAction } from './actions/download';
import { ExpandAction } from './actions/expand';
import { FolderAction } from './actions/folder';
import { FullscreenAction } from './actions/fullscreen';
import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log';
@@ -150,6 +151,8 @@ export class ActionFactory {
return new StatusBarAction(context, action, options?.config);
case INTERNAL_CALLBACK_ACTION:
return new InternalCallbackAction(context, action, options?.config);
case 'folder':
return new FolderAction(context, action, options?.config);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
@@ -1,4 +1,4 @@
import merge from 'lodash-es/merge';
import { merge } from 'lodash-es';
import { Action, TargetedActionContext } from '../types';
import { ActionContext } from 'action';
+3 -2
View File
@@ -1,3 +1,4 @@
import { ViewItemClassifier } from '../view/item-classifier';
import { CardCameraURLAPI } from './types';
export class CameraURLManager {
@@ -20,11 +21,11 @@ export class CameraURLManager {
public getCameraURL(): string | null {
const view = this._api.getViewManager().getView();
const media = view?.queryResults?.getSelectedResult() ?? null;
const item = view?.queryResults?.getSelectedResult() ?? null;
const endpoints = view?.camera
? this._api.getCameraManager().getCameraEndpoints(view.camera, {
view: view.view,
...(media && { media: media }),
...(item && ViewItemClassifier.isMedia(item) && { media: item }),
}) ?? null
: null;
return endpoints?.ui?.endpoint ?? null;
+1 -1
View File
@@ -1,8 +1,8 @@
import { LitElement, ReactiveControllerHost } from 'lit';
import { ActionEventTarget } from '../action-handler-directive';
import { isCardInPanel } from '../ha/panel';
import { setOrRemoveAttribute } from '../utils/basic';
import { isBeingCasted } from '../utils/casting';
import { isCardInPanel } from '../utils/ha';
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
import { InitializationAspect } from './initialization-manager';
import { CardElementAPI } from './types';
+3 -1
View File
@@ -1,4 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { ConditionsManager } from '../../conditions/conditions-manager.js';
import { isConfigUpgradeable } from '../../config/management.js';
import { setProfiles } from '../../config/profiles/set-profiles.js';
@@ -15,6 +15,7 @@ import { CardConfigAPI } from '../types.js';
import { getOverriddenConfig } from './get-overridden-config.js';
import { setAutomationsFromConfig } from './load-automations.js';
import { setRemoteControlEntityFromConfig } from './load-control-entities.js';
import { setFoldersFromConfig } from './load-folders.js';
import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js';
export class ConfigManager {
@@ -136,6 +137,7 @@ export class ConfigManager {
const previousConfig = this._overriddenConfig;
this._overriddenConfig = overriddenConfig;
setFoldersFromConfig(this._api);
this._api.getStyleManager().updateFromConfig();
if (
@@ -1,6 +1,6 @@
import { CardConfigLoaderAPI } from '../types';
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => {
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI): void => {
api.getAutomationsManager().deleteAutomations();
api
.getAutomationsManager()
@@ -0,0 +1,12 @@
import { CardConfigLoaderAPI } from '../types';
export const setFoldersFromConfig = (api: CardConfigLoaderAPI): void => {
api.getFoldersManager().deleteFolders();
try {
api
.getFoldersManager()
.addFolders(api.getConfigManager().getConfig()?.folders ?? []);
} catch (ev) {
api.getMessageManager().setErrorIfHigherPriority(ev);
}
};
+19 -22
View File
@@ -2,17 +2,12 @@ import { ReactiveController } from 'lit';
import { CameraManager } from '../camera-manager/manager';
import { ConditionStateManager } from '../conditions/state-manager';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { DeviceRegistryManager } from '../ha/registry/device';
import { DeviceCache } from '../ha/registry/device/types';
import { EntityRegistryManagerLive } from '../ha/registry/entity';
import { EntityCache, EntityRegistryManager } from '../ha/registry/entity/types';
import { ResolvedMediaCache } from '../ha/resolved-media';
import { LovelaceCardEditor } from '../ha/types';
import {
createDeviceRegistryCache,
DeviceRegistryManager,
} from '../utils/ha/registry/device';
import {
createEntityRegistryCache,
EntityRegistryManagerLive,
} from '../utils/ha/registry/entity';
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { ActionsManager } from './actions/actions-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
@@ -24,8 +19,8 @@ import {
} from './card-element-manager';
import { ConfigManager } from './config/config-manager';
import { DefaultManager } from './default-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FoldersManager } from './folders/manager';
import { FullscreenManager } from './fullscreen/fullscreen-manager';
import { HASSManager } from './hass/hass-manager';
import { InitializationManager } from './initialization-manager';
@@ -65,6 +60,7 @@ import {
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewItemManager } from './view/item-manager';
import { ViewManager } from './view/view-manager';
export class CardController
@@ -98,12 +94,8 @@ export class CardController
// These properties may be used in the construction of 'managers' (and should
// be created first).
protected _deviceRegistryManager = new DeviceRegistryManager(
createDeviceRegistryCache(),
);
protected _entityRegistryManager = new EntityRegistryManagerLive(
createEntityRegistryCache(),
);
protected _deviceRegistryManager = new DeviceRegistryManager(new DeviceCache());
protected _entityRegistryManager = new EntityRegistryManagerLive(new EntityCache());
protected _resolvedMediaCache = new ResolvedMediaCache();
protected _actionsManager = new ActionsManager(this, new TemplateRenderer());
@@ -113,8 +105,8 @@ export class CardController
protected _cardElementManager: CardElementManager;
protected _configManager = new ConfigManager(this);
protected _defaultManager = new DefaultManager(this);
protected _downloadManager = new DownloadManager(this);
protected _expandManager = new ExpandManager(this);
protected _foldersManager = new FoldersManager(this);
protected _fullscreenManager = new FullscreenManager(this);
protected _hassManager = new HASSManager(this);
protected _initializationManager = new InitializationManager(this);
@@ -129,6 +121,7 @@ export class CardController
protected _styleManager = new StyleManager(this);
protected _triggersManager = new TriggersManager(this);
protected _viewManager = new ViewManager(this);
protected _viewItemManager = new ViewItemManager(this);
constructor(
host: CardHTMLElement,
@@ -193,10 +186,6 @@ export class CardController
return this._deviceRegistryManager;
}
public getDownloadManager(): DownloadManager {
return this._downloadManager;
}
public getEntityRegistryManager(): EntityRegistryManager {
return this._entityRegistryManager;
}
@@ -205,6 +194,10 @@ export class CardController
return this._expandManager;
}
public getFoldersManager(): FoldersManager {
return this._foldersManager;
}
public getFullscreenManager(): FullscreenManager {
return this._fullscreenManager;
}
@@ -282,6 +275,10 @@ export class CardController
return this._viewManager;
}
public getViewItemManager(): ViewItemManager {
return this._viewItemManager;
}
// *************************************************************************
// Handlers
// *************************************************************************
+1 -1
View File
@@ -1,4 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { createGeneralAction } from '../utils/action';
import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode';
-41
View File
@@ -1,41 +0,0 @@
import { downloadMedia, downloadURL } from '../utils/download';
import { generateScreenshotFilename } from '../utils/screenshot';
import { CardDownloadAPI } from './types';
export class DownloadManager {
protected _api: CardDownloadAPI;
constructor(api: CardDownloadAPI) {
this._api = api;
}
public async downloadViewerMedia(): Promise<boolean> {
const media = this._api
.getViewManager()
.getView()
?.queryResults?.getSelectedResult();
const hass = this._api.getHASSManager().getHASS();
if (!media || !hass) {
return false;
}
try {
await downloadMedia(hass, this._api.getCameraManager(), media);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return false;
}
return true;
}
public async downloadScreenshot(): Promise<void> {
const url = await this._api
.getMediaLoadedInfoManager()
.get()
?.mediaPlayerController?.getScreenshotURL();
if (url) {
downloadURL(url, generateScreenshotFilename(this._api.getViewManager().getView()));
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { sortItems } from '../view/sort';
import { HAFoldersEngine } from './ha/engine';
import { DownloadHelpers, EngineOptions, FolderQuery, FoldersEngine } from './types';
export class FoldersExecutor {
private _ha: FoldersEngine;
constructor(engines?: { ha?: HAFoldersEngine }) {
this._ha = engines?.ha ?? new HAFoldersEngine();
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
return (
this._getFolderEngine(folder.type)?.generateDefaultFolderQuery(folder) ?? null
);
}
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const results =
(await this._getFolderEngine(query.folder.type)?.expandFolder(
hass,
query,
engineOptions,
)) ?? null;
return results ? sortItems(results) : null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return (
this._getFolderEngine(item.getFolder()?.type)?.getItemCapabilities(item) ?? null
);
}
public async getDownloadPath(
hass: HomeAssistant | null,
item: ViewItem,
helpers?: DownloadHelpers,
): Promise<Endpoint | null> {
return await (this._getFolderEngine(item.getFolder()?.type)?.getDownloadPath(
hass,
item,
helpers,
) ?? null);
}
public async favorite(
hass: HomeAssistant | null,
item: ViewItem,
favorite: boolean,
): Promise<void> {
return await this._getFolderEngine(item.getFolder()?.type)?.favorite(
hass,
item,
favorite,
);
}
private _getFolderEngine(type?: FolderType): FoldersEngine | null {
switch (type) {
case folderTypeSchema.enum.ha:
return this._ha;
}
return null;
}
}
+180
View File
@@ -0,0 +1,180 @@
import { NonEmptyTuple } from 'type-fest';
import {
FolderConfig,
folderTypeSchema,
HA_MEDIA_SOURCE_ROOT,
HAFolderConfig,
HAFolderPathComponent,
} from '../../../config/schema/folders';
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
import { BrowseMedia, BrowseMediaCache } from '../../../ha/browse-media/types';
import {
BrowseMediaStep,
BrowseMediaTarget,
BrowseMediaWalker,
} from '../../../ha/browse-media/walker';
import { getMediaDownloadPath } from '../../../ha/download';
import { HomeAssistant } from '../../../ha/types';
import { Endpoint } from '../../../types';
import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { ViewItemCapabilities } from '../../../view/types';
import {
DownloadHelpers,
EngineOptions,
FolderPathComponent,
FolderQuery,
FoldersEngine,
} from '../types';
export class HAFoldersEngine implements FoldersEngine {
private _browseMediaManager: BrowseMediaWalker;
private _cache = new BrowseMediaCache();
public constructor(browseMediaManager?: BrowseMediaWalker) {
this._browseMediaManager = browseMediaManager ?? new BrowseMediaWalker();
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return {
canFavorite: false,
canDownload: !ViewItemClassifier.isFolder(item),
};
}
public async getDownloadPath(
hass: HomeAssistant,
item: ViewItem,
helpers?: DownloadHelpers,
): Promise<Endpoint | null> {
if (!ViewItemClassifier.isMedia(item)) {
return null;
}
return getMediaDownloadPath(hass, item.getContentID(), helpers?.resolvedMediaCache);
}
public async favorite(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_hass: HomeAssistant,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_item: ViewItem,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_favorite: boolean,
): Promise<void> {
return;
}
public generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null {
if (folder.type !== folderTypeSchema.enum.ha) {
return null;
}
return {
folder,
path: this.getDefaultFolderPathComponents(folder.ha),
};
}
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
if (query.folder.type !== folderTypeSchema.enum.ha) {
return null;
}
const pathComponents = [...query.path];
// Search through the path components from the start to find the last
// component with a precise media source id, which is where the queries
// start (and may drill down from).
let start: string | null = null;
while (pathComponents.length > 0) {
const id = pathComponents[0]?.id;
if (id) {
start = id;
pathComponents.shift();
} else {
break;
}
}
// If no media source id is found, return null, as there is no "starting
// query".
if (start === null) {
return null;
}
// This matcher matches a browse media against a given path component.
const componentMatcher = (
media: BrowseMedia,
component?: FolderPathComponent,
): boolean => {
return (
!component ||
(media.can_expand &&
(component.ha?.title === media.title ||
(component.ha?.title_re &&
new RegExp(component.ha.title_re).test(media.title)) ||
component.id === media.media_content_id))
);
};
// Generate a walk step, optionally matching against the next path component
// (if any), otherwise just returning all the media at this level.
const generateStep = (targets: BrowseMediaTarget[]): BrowseMediaStep[] => {
const nextComponent = pathComponents.shift();
return [
{
targets,
...(nextComponent && {
matcher: (media: BrowseMedia) => componentMatcher(media, nextComponent),
advance: (targets) => generateStep(targets),
}),
},
];
};
const browseMedia = await this._browseMediaManager.walk(
hass,
generateStep([start]),
{
...((engineOptions?.useCache ?? true) && { cache: this._cache }),
},
);
return getViewItemsFromBrowseMediaArray(browseMedia, {
folder: query.folder,
});
}
private getDefaultFolderPathComponents(
haFolderConfig?: HAFolderConfig,
): NonEmptyTuple<FolderPathComponent> {
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id;
const defaultPath = [
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
...(haFolderConfig?.url ?? []),
...(haFolderConfig?.path ?? []),
];
return defaultPath.map((component) =>
this._convertHAPathComponentToFolderPathComponent(component),
) as [FolderPathComponent, ...FolderPathComponent[]];
}
// Convert from the HA folder path component config schema to the general,
// which pulls `path` to the top level.
private _convertHAPathComponentToFolderPathComponent(
component: HAFolderPathComponent,
): FolderPathComponent {
return {
id: component.id,
ha: {
...component,
},
};
}
}
+90
View File
@@ -0,0 +1,90 @@
import { cloneDeep } from 'lodash-es';
import { FolderConfig } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
import { CardFoldersAPI } from '../types';
import { FoldersExecutor } from './executor';
import { EngineOptions, FolderInitializationError, FolderQuery } from './types';
export class FoldersManager {
private _api: CardFoldersAPI;
private _executor: FoldersExecutor;
private _folders: Map<string, FolderConfig> = new Map();
constructor(api: CardFoldersAPI, executor?: FoldersExecutor) {
this._api = api;
this._executor = executor ?? new FoldersExecutor();
}
public deleteFolders(): void {
this._folders.clear();
}
public addFolders(folders: FolderConfig[]): void {
for (const folder of folders) {
const folderNumber = this._folders.size;
const id = folder.id ?? `folder/${folderNumber.toString()}`;
if (this._folders.has(id)) {
throw new FolderInitializationError(
localize('error.duplicate_folder_id'),
folder,
);
}
this._folders.set(id, {
title: `${localize('common.folder')} ${folderNumber}`,
...cloneDeep(folder),
id,
});
}
}
public getFolderCount(): number {
return this._folders.size;
}
public getFolders(): MapIterator<[string, FolderConfig]> {
return this._folders.entries();
}
public getFolder(id?: string): FolderConfig | null {
return id
? this._folders.get(id) ?? null
: this._folders.values().next().value ?? null;
}
public generateDefaultFolderQuery(folder?: FolderConfig): FolderQuery | null {
const _folder = folder ?? this.getFolder();
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
}
public async expandFolder(
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const hass = this._api.getHASSManager().getHASS();
return hass ? this._executor.expandFolder(hass, query, engineOptions) : null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
return this._executor.getItemCapabilities(item);
}
public async getDownloadPath(item: ViewItem): Promise<Endpoint | null> {
return await this._executor.getDownloadPath(
this._api.getHASSManager().getHASS(),
item,
{
resolvedMediaCache: this._api.getResolvedMediaCache(),
},
);
}
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
return await this._executor.favorite(
this._api.getHASSManager().getHASS(),
item,
favorite,
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NonEmptyTuple } from 'type-fest';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
import { AdvancedCameraCardError } from '../../types.js';
import { ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
// ====
// Base
// ====
export interface EngineOptions {
useCache?: boolean;
}
export class FolderInitializationError extends AdvancedCameraCardError {}
// ============
// Folder Query
// ============
export type FolderPathComponent = {
id?: string;
ha?: Omit<HAFolderPathComponent, 'id'>;
};
export interface FolderQuery {
folder: FolderConfig;
// A trail of paths to navigate back to the "root", with the last path being
// the path that this query directly refers to.
path: NonEmptyTuple<FolderPathComponent>;
}
// ===============
// Folders Engines
// ===============
export interface DownloadHelpers {
resolvedMediaCache?: ResolvedMediaCache | null;
}
export interface FoldersEngine {
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
expandFolder(
hass: HomeAssistant,
query: FolderQuery,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null>;
getItemCapabilities(item: ViewItem): ViewItemCapabilities | null;
getDownloadPath(
hass: HomeAssistant | null,
item: ViewItem,
options?: DownloadHelpers,
): Promise<Endpoint | null>;
favorite(hass: HomeAssistant | null, item: ViewItem, favorite: boolean): Promise<void>;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { hasHAConnectionStateChanged } from '../../ha/has-hass-connection-changed';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { hasHAConnectionStateChanged } from '../../utils/ha';
import { CardHASSAPI } from '../types';
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
+2 -2
View File
@@ -1,5 +1,5 @@
import { HomeAssistant } from '../../ha/types';
import { getHassDifferences, HassStateDifference } from '../../utils/ha';
import { getHassDifferences } from '../../ha/get-hass-differences';
import { HassStateDifference, HomeAssistant } from '../../ha/types';
type StateWatcherCallback = (difference: HassStateDifference) => void;
@@ -1,6 +1,6 @@
import PQueue from 'p-queue';
import { loadLanguages } from '../localize/localize';
import { sideLoadHomeAssistantElements } from '../utils/ha';
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
import { Initializer } from '../utils/initializer/initializer';
import { CardInitializerAPI } from './types';
@@ -42,6 +42,10 @@ export class InitializationManager {
return this._everInitialized;
}
public isInitialized(aspect: InitializationAspect): boolean {
return this._initializer.isInitialized(aspect);
}
public isInitializedMandatory(): boolean {
const config = this._api.getConfigManager().getConfig();
if (!config) {
+1 -1
View File
@@ -1,4 +1,4 @@
import throttle from 'lodash-es/throttle';
import { throttle } from 'lodash-es';
import { setOrRemoveAttribute } from '../utils/basic';
import { Timer } from '../utils/timer';
import { CardInteractionAPI } from './types';
@@ -1,5 +1,5 @@
import { CardKeyboardStateAPI, KeysState } from './types';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
export class KeyboardStateManager {
protected _api: CardKeyboardStateAPI;
+5 -5
View File
@@ -5,12 +5,12 @@ import {
MEDIA_PLAYER_SUPPORT_STOP,
MEDIA_PLAYER_SUPPORT_TURN_OFF,
} from '../const';
import { Entity } from '../ha/registry/entity/types';
import { supportsFeature } from '../ha/supports-feature';
import { localize } from '../localize/localize';
import { errorToConsole } from '../utils/basic';
import { supportsFeature } from '../utils/ha';
import { Entity } from '../utils/ha/registry/entity/types';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import { ViewMedia } from '../view/item';
import { ViewItemClassifier } from '../view/item-classifier';
import { CardMediaPlayerAPI } from './types';
export class MediaPlayerManager {
@@ -196,7 +196,7 @@ export class MediaPlayerManager {
await hass.callService('media_player', 'play_media', {
entity_id: mediaPlayer,
media_content_id: media.getContentID(),
media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image',
media_content_type: ViewItemClassifier.isVideo(media) ? 'video' : 'image',
extra: {
...(title && { title: title }),
...(thumbnail && { thumb: thumbnail }),
@@ -1,4 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager';
import { StatusBarItem } from '../config/schema/actions/types';
import { StatusBarConfig } from '../config/schema/status-bar';
+1 -2
View File
@@ -1,5 +1,4 @@
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import { orderBy, throttle } from 'lodash-es';
import { CameraEvent } from '../camera-manager/types';
import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types';
+17 -4
View File
@@ -1,16 +1,16 @@
import type { CameraManager } from '../camera-manager/manager';
import type { ConditionStateManager } from '../conditions/state-manager';
import type { Automation } from '../config/schema/automations';
import type { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
import type { EntityRegistryManager } from '../ha/registry/entity/types';
import type { ResolvedMediaCache } from '../ha/resolved-media';
import type { ActionsManager } from './actions/actions-manager';
import type { AutomationsManager } from './automations-manager';
import type { CameraURLManager } from './camera-url-manager';
import type { CardElementManager } from './card-element-manager';
import type { ConfigManager } from './config/config-manager';
import type { DefaultManager } from './default-manager';
import type { DownloadManager } from './download-manager';
import type { ExpandManager } from './expand-manager';
import type { FoldersManager } from './folders/manager';
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
import type { HASSManager } from './hass/hass-manager';
import type { InitializationManager } from './initialization-manager';
@@ -24,6 +24,7 @@ import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewItemManager } from './view/item-manager';
import type { ViewManager } from './view/view-manager';
// *************************************************************************
@@ -40,8 +41,8 @@ export interface CardActionsAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getDownloadManager(): DownloadManager;
getExpandManager(): ExpandManager;
getFoldersManager(): FoldersManager;
getFullscreenManager(): FullscreenManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
@@ -50,6 +51,7 @@ export interface CardActionsAPI {
getMicrophoneManager(): MicrophoneManager;
getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager;
getViewItemManager(): ViewItemManager;
getViewManager(): ViewManager;
}
export type CardActionsManagerAPI = CardActionsAPI;
@@ -90,6 +92,7 @@ export interface CardConfigAPI {
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getDefaultManager(): DefaultManager;
getFoldersManager(): FoldersManager;
getHASSManager(): HASSManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
@@ -104,6 +107,8 @@ export interface CardConfigAPI {
export interface CardConfigLoaderAPI {
getAutomationsManager(): AutomationsManager;
getConfigManager(): ConfigManager;
getFoldersManager(): FoldersManager;
getMessageManager(): MessageManager;
getHASSManager(): HASSManager;
}
@@ -148,6 +153,12 @@ export interface CardExpandAPI {
getFullscreenManager(): FullscreenManager;
}
export interface CardFoldersAPI {
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getResolvedMediaCache(): ResolvedMediaCache;
}
export interface CardFullscreenAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
@@ -270,7 +281,9 @@ export interface CardViewAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getFoldersManager(): FoldersManager;
getHASSManager(): HASSManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager;
+131
View File
@@ -0,0 +1,131 @@
import { format } from 'date-fns';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { downloadURL } from '../../utils/download';
import { homeAssistantSignPath } from '../../ha/sign-path';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
import { CardViewAPI } from '../types';
enum ViewMediaSource {
Camera = 'camera',
Folder = 'folder',
}
export class ViewItemManager {
private _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getCapabilities(item: ViewItem): ViewItemCapabilities | null {
const source = this._getMediaSource(item);
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
return this._api.getCameraManager().getMediaCapabilities(item);
}
if (source === ViewMediaSource.Folder) {
return this._api.getFoldersManager().getItemCapabilities(item);
}
return null;
}
public async download(item: ViewItem): Promise<boolean> {
try {
await this._download(item);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return false;
}
return true;
}
public async favorite(item: ViewItem, favorite: boolean): Promise<void> {
const source = this._getMediaSource(item);
if (source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)) {
return await this._api.getCameraManager().favoriteMedia(item, favorite);
}
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (source === ViewMediaSource.Folder) {
return this._api.getFoldersManager().favorite(item, favorite);
}
}
private _getMediaSource(item: ViewItem): ViewMediaSource | null {
if (ViewItemClassifier.isMedia(item) && item.getCameraID()) {
return ViewMediaSource.Camera;
}
if (ViewItemClassifier.isFolder(item) || item.getFolder()) {
return ViewMediaSource.Folder;
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
private async _download(item: ViewItem): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
const source = this._getMediaSource(item);
const endpoint =
source === ViewMediaSource.Camera && ViewItemClassifier.isMedia(item)
? await this._api.getCameraManager().getMediaDownloadPath(item)
: source === ViewMediaSource.Folder
? await this._api.getFoldersManager().getDownloadPath(item)
: null;
if (!endpoint) {
throw new AdvancedCameraCardError(localize('error.download_no_media'));
}
let finalURL = endpoint.endpoint;
if (endpoint.sign) {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, endpoint.endpoint);
} catch (e) {
errorToConsole(e as Error);
}
if (!response) {
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
}
finalURL = response;
}
downloadURL(finalURL, this._generateDownloadFilename(item));
}
private _generateDownloadFilename(item: ViewItem): string {
const toFilename = (input: string): string => {
return input.toLowerCase().replaceAll(/[^a-z0-9]/gi, '-');
};
if (ViewItemClassifier.isMedia(item)) {
const cameraID = item.getCameraID();
const id = item.getID();
const startTime = item.getStartTime();
return (
(cameraID ? toFilename(cameraID) : 'media') +
(id ? `_${toFilename(id)}` : '') +
(startTime ? `_${format(startTime, `yyyy-MM-dd-HH-mm-ss`)}` : '') +
('.' + (item.getMediaType() === 'clip' ? 'mp4' : 'jpg'))
);
}
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (ViewItemClassifier.isFolder(item)) {
return toFilename(item.getTitle() ?? 'media');
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return 'download';
}
}
@@ -1,16 +1,13 @@
import { MediaQueries } from '../../../view/media-queries';
import { MediaQueriesResults } from '../../../view/media-queries-results';
import { Query } from '../../../view/query';
import { QueryResults } from '../../../view/query-results';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SetQueryViewModifier implements ViewModifier {
protected _query?: MediaQueries | null;
protected _queryResults?: MediaQueriesResults | null;
protected _query?: Query | null;
protected _queryResults?: QueryResults | null;
constructor(options?: {
query?: MediaQueries | null;
queryResults?: MediaQueriesResults | null;
}) {
constructor(options?: { query?: Query | null; queryResults?: QueryResults | null }) {
this._query = options?.query;
this._queryResults = options?.queryResults;
}
+71 -26
View File
@@ -1,13 +1,17 @@
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaIndex } from '../../utils/find-best-media-index';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
import { ViewItem } from '../../view/item';
import {
EventMediaQueries,
EventMediaQuery,
FolderViewQuery,
MediaQueries,
RecordingMediaQueries,
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
Query,
RecordingMediaQuery,
} from '../../view/query';
import { QueryClassifier } from '../../view/query-classifier';
import { QueryResults } from '../../view/query-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryExecutorResult } from './types';
@@ -48,14 +52,8 @@ export class QueryExecutor {
if (!rawQueries) {
return null;
}
const queries = new EventMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results
? {
query: queries,
queryResults: results,
}
: null;
const queries = new EventMediaQuery(rawQueries);
return await this.executeMediaQuery(queries, options?.executorOptions);
}
public async executeDefaultRecordingQuery(options?: {
@@ -76,16 +74,30 @@ export class QueryExecutor {
if (!rawQueries) {
return null;
}
const queries = new RecordingMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results ? { query: queries, queryResults: results } : null;
const queries = new RecordingMediaQuery(rawQueries);
return await this.executeMediaQuery(queries, options?.executorOptions);
}
public async execute(
public async executeQuery(
query: Query,
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
/* istanbul ignore else: this path cannot be reached -- @preserve */
if (QueryClassifier.isMediaQuery(query)) {
return await this.executeMediaQuery(query, executorOptions);
} else if (QueryClassifier.isFolderQuery(query)) {
return await this._executeFolderQuery(query, executorOptions);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
public async executeMediaQuery(
query: MediaQueries,
executorOptions?: QueryExecutorOptions,
): Promise<MediaQueriesResults | null> {
const queries = query.getQueries();
): Promise<QueryExecutorResult | null> {
const queries = query.getQuery();
if (!queries) {
return null;
}
@@ -95,11 +107,17 @@ export class QueryExecutor {
.executeMediaQueries<MediaQuery>(queries, {
useCache: executorOptions?.useCache,
});
if (!mediaArray) {
return null;
}
const queryResults = mediaArray
? this._generateQueriesResults(mediaArray, executorOptions)
: null;
return queryResults ? { query, queryResults } : null;
}
const queryResults = new MediaQueriesResults({ results: mediaArray });
private _generateQueriesResults(
itemArray: ViewItem[],
executorOptions?: QueryExecutorOptions,
): QueryResults | null {
const queryResults = new QueryResults({ results: itemArray });
if (executorOptions?.rejectResults?.(queryResults)) {
return null;
}
@@ -111,9 +129,9 @@ export class QueryExecutor {
} else if (executorOptions?.selectResult?.func) {
queryResults.selectResultIfFound(executorOptions.selectResult.func);
} else if (executorOptions?.selectResult?.time) {
queryResults.selectBestResult((media) =>
findBestMediaIndex(
media,
queryResults.selectBestResult((itemArray) =>
findBestMediaTimeIndex(
itemArray,
executorOptions.selectResult?.time?.time as Date,
executorOptions.selectResult?.time?.favorCameraID,
),
@@ -122,6 +140,33 @@ export class QueryExecutor {
return queryResults;
}
public async executeDefaultFolderQuery(
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
const query = this._api.getFoldersManager().generateDefaultFolderQuery();
return query
? this._executeFolderQuery(new FolderViewQuery(query), executorOptions)
: null;
}
private async _executeFolderQuery(
query: FolderViewQuery,
executorOptions?: QueryExecutorOptions,
): Promise<QueryExecutorResult | null> {
const rawQuery = query.getQuery();
if (!rawQuery) {
return null;
}
const itemArray = await this._api
.getFoldersManager()
.expandFolder(rawQuery, { useCache: executorOptions?.useCache });
const queryResults = itemArray
? this._generateQueriesResults(itemArray, executorOptions)
: null;
return queryResults ? { query, queryResults } : null;
}
protected _getChunkLimit(): number {
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
return (
+23
View File
@@ -0,0 +1,23 @@
import { orderBy, uniqBy } from 'lodash-es';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
export const sortItems = <T extends ViewItem>(itemArray: T[]): T[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(itemArray, (item) => item.getID() ?? item),
[
// Pull folders to the front.
(item) => !ViewItemClassifier.isFolder(item),
// Sort by time and id.
(item) =>
ViewItemClassifier.isMedia(item)
? item.getStartTime() ?? item.getID()
: item.getID(),
],
['asc', 'asc'],
);
};
+7 -7
View File
@@ -1,9 +1,9 @@
import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const.js';
import { AdvancedCameraCardError } from '../../types.js';
import { MediaQueriesResults } from '../../view/media-queries-results.js';
import { MediaQueries } from '../../view/media-queries.js';
import { ViewMedia } from '../../view/media.js';
import { ViewItem } from '../../view/item.js';
import { QueryResults } from '../../view/query-results.js';
import { Query } from '../../view/query.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
@@ -20,15 +20,15 @@ export interface QueryExecutorOptions {
favorCameraID?: string;
};
id?: string;
func?: (media: ViewMedia) => boolean;
func?: (media: ViewItem) => boolean;
};
rejectResults?: (results: MediaQueriesResults) => boolean;
rejectResults?: (results: QueryResults) => boolean;
useCache?: boolean;
}
export interface QueryExecutorResult {
query: MediaQueries;
queryResults: MediaQueriesResults;
query: Query;
queryResults: QueryResults;
}
export interface ViewFactoryOptions {
+28 -4
View File
@@ -2,9 +2,10 @@ import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { MediaQueriesClassifier } from '../../view/media-queries-classifier';
import { QueryClassifier } from '../../view/query-classifier';
import { View } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
import { applyViewModifiers } from './modifiers';
@@ -103,6 +104,10 @@ export class ViewManager implements ViewManagerInterface {
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
if (!this._isAllowedToSetView()) {
return;
}
let view: View | null = null;
try {
view = viewFactoryFunc({
@@ -112,7 +117,9 @@ export class ViewManager implements ViewManagerInterface {
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
if (view) {
this._setView(view);
}
}
protected _markViewLoadingQuery(view: View, index: number): View {
@@ -122,6 +129,19 @@ export class ViewManager implements ViewManagerInterface {
return view.removeContextProperty('loading', 'query');
}
protected _isAllowedToSetView(): boolean {
// It is possible to have a race condition where the view is being set at
// the same time as the cameras being initialized. Test case: Open
// folder-based media in the media viewer carousel, then attempt to edit the
// card -- this causes the cameras to re-initialize at the same time as
// folder media is reporting observed zoom settings in the view context.
// Without this check, that will result in a "No cameras support this view"
// message.
return this._api
.getInitializationManager()
.isInitialized(InitializationAspect.CAMERAS);
}
protected async _setViewThenModifyAsync(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
viewModifiersFunc: (
@@ -130,6 +150,10 @@ export class ViewManager implements ViewManagerInterface {
) => Promise<ViewModifier[] | null>,
options?: ViewFactoryOptions,
): Promise<void> {
if (!this._isAllowedToSetView()) {
return;
}
let initialView: View | null = null;
try {
initialView = viewFactoryFunc({
@@ -220,10 +244,10 @@ export class ViewManager implements ViewManagerInterface {
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
const switchingFromViewerToGallery =
this._view?.isViewerView() && newView?.isGalleryView();
this._view?.isViewerView() && newView?.isMediaGalleryView();
const newMediaType = newView?.getDefaultMediaType();
const alreadyHasMatchingQuery =
MediaQueriesClassifier.getMediaType(this._view?.query) === newMediaType;
QueryClassifier.getMediaType(this._view?.query) === newMediaType;
return !!switchingFromViewerToGallery && alreadyHasMatchingQuery;
}
@@ -29,7 +29,9 @@ export class ViewQueryExecutor {
return view.query
? [
new SetQueryViewModifier({
queryResults: await this._executor.execute(view.query, queryExecutorOptions),
queryResults: (
await this._executor.executeQuery(view.query, queryExecutorOptions)
)?.queryResults,
}),
]
: [];
@@ -83,6 +85,12 @@ export class ViewQueryExecutor {
return results ? [new SetQueryViewModifier(results)] : [];
};
const executeFolderQuery = async (): Promise<ViewModifier[]> => {
const results =
await this._executor.executeDefaultFolderQuery(queryExecutorOptions);
return results ? [new SetQueryViewModifier(results)] : [];
};
switch (view.view) {
case 'live':
if (config.live.controls.thumbnails.mode !== 'none') {
@@ -111,6 +119,9 @@ export class ViewQueryExecutor {
case 'recordings':
viewModifiers.push(...(await executeMediaQuery(mediaType)));
break;
case 'folder':
viewModifiers.push(...(await executeFolderQuery()));
break;
}
viewModifiers.push(...this._getTimelineWindowViewModifier(view));
+5 -3
View File
@@ -247,12 +247,13 @@ class AdvancedCameraCard extends LitElement {
this._config,
this._controller.getCameraManager(),
{
inExpandedMode: this._controller.getExpandManager().isExpanded(),
fullscreenManager: this._controller.getFullscreenManager(),
currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
foldersManager: this._controller.getFoldersManager(),
fullscreenManager: this._controller.getFullscreenManager(),
inExpandedMode: this._controller.getExpandManager().isExpanded(),
mediaPlayerController: this._controller.getMediaPlayerManager(),
microphoneManager: this._controller.getMicrophoneManager(),
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
view: view,
viewManager: this._controller.getViewManager(),
},
@@ -371,6 +372,7 @@ class AdvancedCameraCard extends LitElement {
.hass=${this._hass}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.viewItemManager=${this._controller.getViewItemManager()}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.config=${this._controller.getConfigManager().getConfig()}
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
+49
View File
@@ -0,0 +1,49 @@
import { ViewManagerEpoch } from '../../card-controller/view/types';
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
import { ViewFolder, ViewItem } from '../../view/item';
import { QueryClassifier } from '../../view/query-classifier';
import { View } from '../../view/view';
export const upFolderClickHandler = (
_item: ViewItem,
ev: Event,
viewManagerEpoch?: ViewManagerEpoch,
): void => {
stopEventFromActivatingCardWideActions(ev);
const query = viewManagerEpoch?.manager.getView()?.query;
if (!query || !QueryClassifier.isFolderQuery(query)) {
return;
}
const rawQuery = query?.getQuery();
if (!rawQuery?.path || rawQuery?.path.length <= 1) {
return;
}
const path = rawQuery.path.slice(0, -1);
viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: query.clone().setQuery({
folder: rawQuery.folder,
path: [path[0], ...path.slice(1)],
}),
},
});
};
export const getUpFolderMediaItem = (view?: View | null): ViewFolder | null => {
const query = view?.query;
if (!query || !QueryClassifier.isFolderQuery(query)) {
return null;
}
const rawQuery = query.getQuery();
if (!rawQuery?.folder || !rawQuery?.path || rawQuery.path.length <= 1) {
return null;
}
return new ViewFolder(rawQuery.folder, {
icon: 'mdi:arrow-up-left',
});
};
@@ -0,0 +1,81 @@
import { ViewManagerInterface } from '../../card-controller/view/types';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { QueryClassifier } from '../../view/query-classifier';
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
// The minimum width of a (folder) thumbnail with details enabled. This is
// shorter than for regular camera media as this will consist of just a name.
export const FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
export class FolderGalleryController {
private _host: HTMLElement;
public constructor(host: HTMLElement) {
this._host = host;
}
public setThumbnailSize(size?: number): void {
this._host.style.setProperty(
'--advanced-camera-card-thumbnail-size',
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
);
}
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
return !thumbnailConfig
? THUMBNAIL_WIDTH_DEFAULT
: thumbnailConfig.show_details
? FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
: thumbnailConfig.size;
}
public getColumnCountRoundMethod(
thumbnailConfig?: MediaGalleryThumbnailsConfig,
): GalleryColumnCountRoundMethod {
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
}
public itemClickHandler(
viewManager: ViewManagerInterface,
item: ViewItem,
ev: Event,
): void {
stopEventFromActivatingCardWideActions(ev);
const view = viewManager.getView();
if (!view) {
return;
}
if (ViewItemClassifier.isMedia(item)) {
viewManager.setViewByParameters({
params: {
view: 'media',
queryResults: view.queryResults
?.clone()
.selectResultIfFound((result) => result === item),
},
});
} else if (
ViewItemClassifier.isFolder(item) &&
QueryClassifier.isFolderQuery(view.query)
) {
const rawQuery = view.query.getQuery();
const id = item.getID();
if (!rawQuery || !id) {
return;
}
viewManager.setViewByParametersWithExistingQuery({
params: {
query: view.query.clone().setQuery({
folder: rawQuery.folder,
path: [...rawQuery.path, { id }],
}),
},
});
}
}
}
@@ -0,0 +1,250 @@
import { LitElement, ReactiveController } from 'lit';
import { throttle } from 'lodash-es';
import { GalleryExtendEvent } from '../../components/gallery/types';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
import { scrollIntoView } from '../../utils/scroll';
import { sleep } from '../../utils/sleep';
const GALLERY_MIN_EXTENSION_SECONDS = 0.5;
export type GalleryColumnCountRoundMethod = 'ceil' | 'floor';
interface GalleryCoreOptions {
columnWidth?: number;
columnCountRoundMethod?: GalleryColumnCountRoundMethod;
extendUp?: boolean;
extendDown?: boolean;
}
export class GalleryCoreController implements ReactiveController {
private _host: LitElement;
private _intersectionObserver: IntersectionObserver;
private _resizeObserver: ResizeObserver;
private _options: GalleryCoreOptions | null = null;
private _touchScrollYPosition: number | null = null;
// Wheel / touch events may be voluminous, throttle extension calls.
private _throttledExtendUp = throttle(
this._extendUp.bind(this),
GALLERY_MIN_EXTENSION_SECONDS * 1000,
{
leading: true,
trailing: false,
},
);
private _getSlot: () => HTMLSlotElement | null;
private _getSentintelBottom: () => HTMLElement | null;
private _showLoaderTop: (show: boolean) => void;
private _showSentinelBottom: (show: boolean) => void;
private _wasEverNonEmpty = false;
constructor(
host: LitElement,
getSlot: () => HTMLSlotElement | null,
getSentinelBottom: () => HTMLElement | null,
showLoaderTopCallback: (show: boolean) => void,
showSentinelBottomCallback: (show: boolean) => void,
) {
this._host = host;
this._host.addController(this);
this._getSlot = getSlot;
this._getSentintelBottom = getSentinelBottom;
this._showLoaderTop = showLoaderTopCallback;
this._showSentinelBottom = showSentinelBottomCallback;
this._resizeObserver = new ResizeObserver(() => this._setColumnCount());
this._intersectionObserver = new IntersectionObserver(
async (entries: IntersectionObserverEntry[]): Promise<void> => {
if (entries.some((entry) => entry.isIntersecting)) {
await this._extendDown();
}
},
);
}
public removeController(): void {
this._host.removeController(this);
}
public setOptions(options: GalleryCoreOptions): void {
this._options = options;
this._setColumnCount();
}
public hostConnected(): void {
this._resizeObserver.observe(this._host);
// Since the scroll event does not fire if the user is already at the top of
// the container, instead we manually use the wheel and touchstart/end
// events to detect "top upwards scrolling" (to trigger an extension of the
// gallery).
this._host.addEventListener('wheel', this._wheelHandler, { passive: true });
this._host.addEventListener('touchstart', this._touchStartHandler, {
passive: true,
});
this._host.addEventListener('touchend', this._touchEndHandler);
// Request update in order to ensure the intersection observer reconnects
// with the loader sentinel.
this._host.requestUpdate();
}
public hostDisconnected(): void {
this._host.removeEventListener('wheel', this._wheelHandler);
this._host.removeEventListener('touchstart', this._touchStartHandler);
this._host.removeEventListener('touchend', this._touchEndHandler);
this._resizeObserver.disconnect();
this._intersectionObserver.disconnect();
}
public hostUpdated(): void {
const sentinel = this._getSentintelBottom();
this._intersectionObserver.disconnect();
if (sentinel) {
this._intersectionObserver.observe(sentinel);
}
}
private _setColumnCount(): void {
if (!this._options?.columnWidth) {
return;
}
const roundFunc =
this._options.columnCountRoundMethod === 'ceil' ? Math.ceil : Math.floor;
const columns = Math.max(
1,
roundFunc(this._host.clientWidth / this._options.columnWidth),
);
this._host.style.setProperty(
'--advanced-camera-card-gallery-columns',
String(columns),
);
}
private _touchStartHandler = (ev: TouchEvent): void => {
// Remember the Y touch position on touch start, so that we can calculate if
// the user gestured upwards or downards on touchend.
if (ev.touches.length === 1) {
this._touchScrollYPosition = ev.touches[0].screenY;
} else {
this._touchScrollYPosition = null;
}
};
private _touchEndHandler = async (ev: TouchEvent): Promise<void> => {
if (
!this._host.scrollTop &&
ev.changedTouches.length === 1 &&
this._touchScrollYPosition !== null
) {
if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
await this._throttledExtendUp();
}
}
this._touchScrollYPosition = null;
};
private _wheelHandler = async (ev: WheelEvent): Promise<void> => {
if (!this._host.scrollTop && ev.deltaY < 0) {
await this._throttledExtendUp();
}
};
private async _extendUp(): Promise<void> {
if (!this._options?.extendUp) {
return;
}
this._showLoaderTop(true);
const start = new Date();
await this._waitForExtend('up');
const delta = new Date().getTime() - start.getTime();
if (delta < GALLERY_MIN_EXTENSION_SECONDS * 1000) {
// Hidden gem: "legitimate" (?!) use of sleep() :-) These calls can return
// very quickly even with caching disabled since the time window
// constraints on the query will usually be very narrow and the backend
// can thus very quickly reply. It's often so fast it actually looks like
// a rendering issue where the progress indictor barely registers before
// it's gone again. This optional pause ensures there is at least some
// visual feedback to the user that lasts long enough they can 'feel' the
// fetch has happened.
//
// This is only applied on the 'up' extend since the 'down' extend may be
// called multiple times for large card sizes (e.g. fullscreen) where a
// delay is not desirable.
await sleep(GALLERY_MIN_EXTENSION_SECONDS - delta / 1000);
}
this._showLoaderTop(false);
}
private async _extendDown(): Promise<void> {
if (!this._options?.extendDown) {
return;
}
this._showSentinelBottom(false);
await this._waitForExtend('down');
// Sentinel will be re-shown next time the contents changes, see:
// updateContents() .
}
private async _waitForExtend(direction: 'up' | 'down'): Promise<void> {
await new Promise<void>((resolve) => {
fireAdvancedCameraCardEvent<GalleryExtendEvent>(
this._host,
`gallery:extend:${direction}`,
{ resolve },
{
bubbles: false,
composed: false,
},
);
});
}
public updateContents(): void {
const slot = this._getSlot();
if (!slot) {
return;
}
const contents = slot
.assignedElements()
.filter((element) => element instanceof HTMLElement);
const firstSelected = contents.find(
(element) => element.getAttribute('selected') !== null,
);
if (contents.length) {
if (!this._wasEverNonEmpty && firstSelected) {
// As a special case, if this is the first setting of the slot contents,
// the gallery is scrolled to the selected element (if any). This is
// only done on the first setting, as subsequent gallery extensions
// should not cause the gallery to rescroll to the item that happens to
// be selected.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/885
scrollIntoView(firstSelected, {
boundary: this._host,
block: 'center',
});
}
this._wasEverNonEmpty = true;
}
// Always render the bottom sentinel when the contents changes, in order to allow
// the gallery to be extended downwards.
this._showSentinelBottom(true);
}
}
@@ -0,0 +1,156 @@
import { CameraManager, ExtendedMediaQueryResult } from '../../camera-manager/manager';
import { EventQuery, MediaQuery, RecordingQuery } from '../../camera-manager/types';
import {
ViewManagerEpoch,
ViewManagerInterface,
} from '../../card-controller/view/types';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
import { errorToConsole } from '../../utils/basic';
import { ViewItem } from '../../view/item';
import { EventMediaQuery, RecordingMediaQuery } from '../../view/query';
import { QueryClassifier } from '../../view/query-classifier';
import { QueryResults } from '../../view/query-results';
import { View } from '../../view/view';
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
// The minimum width of a thumbnail with details enabled.
export const MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
export class MediaGalleryController {
private _host: HTMLElement;
private _media: ViewItem[] | null = null;
public constructor(host: HTMLElement) {
this._host = host;
}
public getMedia(): ViewItem[] | null {
return this._media;
}
public setMediaFromView(newView?: View | null, oldView?: View | null): void {
const newResults = newView?.queryResults?.getResults() ?? null;
if (newResults === null) {
this._media = null;
return;
}
if (!this._media || oldView?.queryResults?.getResults() !== newResults) {
// Media gallery places the most recent media at the top (the query
// results place the most recent media at the end for use in the viewer).
// This is copied to a new array to avoid reversing the query results in
// place.
this._media = [...newResults].reverse();
}
}
public setThumbnailSize(size?: number): void {
this._host.style.setProperty(
'--advanced-camera-card-thumbnail-size',
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
);
}
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
return !thumbnailConfig
? THUMBNAIL_WIDTH_DEFAULT
: thumbnailConfig.show_details
? MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
: thumbnailConfig.size;
}
public getColumnCountRoundMethod(
thumbnailConfig?: MediaGalleryThumbnailsConfig,
): GalleryColumnCountRoundMethod {
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
}
public async extendMediaGallery(
cameraManager: CameraManager,
viewManagerEpoch: ViewManagerEpoch,
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
const view = viewManagerEpoch.manager.getView();
if (!view) {
return;
}
const query = view.query;
const existingMedia = view.queryResults?.getResults();
if (!existingMedia || !query || !QueryClassifier.isMediaQuery(query)) {
return;
}
const rawQueries = query.getQuery() ?? null;
if (!rawQueries) {
return;
}
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
try {
extension = await cameraManager.extendMediaQueries<MediaQuery>(
rawQueries,
existingMedia,
direction,
{
useCache: useCache,
},
);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (extension) {
const newMediaQueries = QueryClassifier.isEventQuery(query)
? new EventMediaQuery(extension.queries as EventQuery[])
: QueryClassifier.isRecordingQuery(query)
? new RecordingMediaQuery(extension.queries as RecordingQuery[])
: /* istanbul ignore next: this path cannot be reached -- @preserve */
null;
/* istanbul ignore else: this path cannot be reached, as we explicitly
check for media queries above -- @preserve */
if (newMediaQueries) {
viewManagerEpoch.manager.setViewByParameters({
baseView: view,
params: {
query: newMediaQueries,
queryResults: new QueryResults({
results: extension.results,
}).selectResultIfFound(
(media) => media === view.queryResults?.getSelectedResult(),
),
},
});
}
}
}
public itemClickHandler(
viewManager: ViewManagerInterface,
reversedIndex: number,
ev: Event,
): void {
stopEventFromActivatingCardWideActions(ev);
const view = viewManager.getView();
if (!view || !this._media?.length) {
return;
}
viewManager.setViewByParameters({
params: {
view: 'media',
queryResults: view.queryResults?.clone().selectIndex(
// Media in the gallery is reversed vs the queryResults (see
// note above).
this._media.length - reversedIndex - 1,
),
},
});
}
}
@@ -1,5 +1,5 @@
import { LitElement, ReactiveController } from 'lit';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { KeyboardShortcut } from '../config/schema/view';
import { setOrRemoveAttribute } from '../utils/basic';
@@ -5,8 +5,8 @@ export const getTechnologyForVideoRTC = (
element: VideoRTC,
): MediaTechnology[] | undefined => {
const tech = [
...(!!element.pc ? ['webrtc'] : []),
...(!element.pc && element.mseCodecs ? ['mse', 'hls'] : []),
...(!!element.pc ? ['webrtc' as const] : []),
...(!element.pc && element.mseCodecs ? ['mse' as const, 'hls' as const] : []),
];
return tech.length ? tech : undefined;
};
+19 -16
View File
@@ -11,19 +11,17 @@ import {
sub,
} from 'date-fns';
import { LitElement } from 'lit';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import uniqWith from 'lodash-es/uniqWith';
import { isEqual, orderBy, uniqWith } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager';
import { DateRange, PartialDateRange } from '../camera-manager/range';
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
import { CameraQuery, MediaMetadata, QueryType } from '../camera-manager/types';
import { ViewManagerInterface } from '../card-controller/view/types';
import { SelectOption, SelectValues } from '../components/select';
import { CardWideConfig } from '../config/schema/types';
import { localize } from '../localize/localize';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
import { QueryClassifier } from '../view/query-classifier';
interface MediaFilterControls {
events: boolean;
@@ -216,7 +214,7 @@ export class MediaFilterController {
const what = getArrayValueAsSet(values.what);
const tags = getArrayValueAsSet(values.tags);
const queries = new EventMediaQueries([
const queries = new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: cameraIDs,
@@ -246,7 +244,7 @@ export class MediaFilterController {
},
});
} else {
const queries = new RecordingMediaQueries([
const queries = new RecordingMediaQuery([
{
type: QueryType.Recording,
cameraIDs: cameraIDs,
@@ -283,9 +281,14 @@ export class MediaFilterController {
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
const view = this._viewManager?.getView();
const queries = view?.query?.getQueries();
const query = view?.query;
const allCameraIDs = this._getAllCameraIDs(cameraManager);
if (!view || !queries || !allCameraIDs.size) {
if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) {
return;
}
const queries = query.getQuery();
if (!queries) {
return;
}
@@ -297,7 +300,7 @@ export class MediaFilterController {
let tags: string[] | undefined;
const cameraIDSets = uniqWith(
queries.map((query: DataQuery) => query.cameraIDs),
queries.map((query: CameraQuery) => query.cameraIDs),
isEqual,
);
// Special note: If all visible cameras are selected, this is the same as no
@@ -317,8 +320,8 @@ export class MediaFilterController {
}
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (MediaQueriesClassifier.areEventQueries(view.query)) {
const queries = view.query.getQueries();
if (QueryClassifier.isEventQuery(view.query)) {
const queries = view.query.getQuery();
/* istanbul ignore if: the if path cannot be reached -- @preserve */
if (!queries) {
@@ -362,7 +365,7 @@ export class MediaFilterController {
if (tagsSets.length === 1 && queries[0].tags?.size) {
tags = [...queries[0].tags];
}
} else if (MediaQueriesClassifier.areRecordingQueries(view.query)) {
} else if (QueryClassifier.isRecordingQuery(view.query)) {
mediaType = MediaFilterMediaType.Recordings;
}
@@ -440,8 +443,8 @@ export class MediaFilterController {
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
const view = this._viewManager?.getView();
const events = MediaQueriesClassifier.areEventQueries(view?.query);
const recordings = MediaQueriesClassifier.areRecordingQueries(view?.query);
const events = QueryClassifier.isEventQuery(view?.query);
const recordings = QueryClassifier.isRecordingQuery(view?.query);
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
return {

Some files were not shown because too many files have changed in this diff Show More