feat: Add folder title matching and parsing (#2067)
This is a breaking change for users of the [experimental 'folder' functionality](https://card.camera/#/configuration/folders). Related: #1748
This commit is contained in:
+199
-22
@@ -21,7 +21,7 @@ folders:
|
|||||||
|
|
||||||
## `ha`
|
## `ha`
|
||||||
|
|
||||||
Used to specify a Home Assistant media folder.
|
Used to specify a path to Home Assistant media.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
folders:
|
folders:
|
||||||
@@ -30,19 +30,29 @@ folders:
|
|||||||
# [...]
|
# [...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| 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`. |
|
| `url` | | An optional Home Assistant `Media` browser URL to use as the query base. If `path` is also specified, those matchers/parsers 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. |
|
| `path` | [`{ id: media-source:// }`] | An optional array of parsers and matchers to dynamically compare and extract metadata from the Home Assistant media folder hierarchy. See below. |
|
||||||
|
|
||||||
?> `url` is never fetched, nor sent over the network. It is only processed
|
?> `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.
|
locally in your browser. The host part of the URL can optionally be removed.
|
||||||
|
|
||||||
### `path`
|
### `path`
|
||||||
|
|
||||||
An array of matchers to navigate "down" a folder hierarchy. If `url` is also
|
An array of values that represents the path to a Home Assistant media item, e.g.
|
||||||
specified, matchers are applied starting at that folder, otherwise they are
|
a media item at a path of `one/two/three` would be represented by a path array
|
||||||
applied at the media source root (i.e. `media-source://`).
|
of length three.
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `id` | | An optional exact media item to select, usually the parent of where `matchers` and `parsers` should apply. |
|
||||||
|
| `matchers` | | An optional array of matchers to evaluate whether to return a given media item. If no matcher is specified, everything in the given folder matches. See below. |
|
||||||
|
| `parsers` | | An optional array of parsers that extract data out of a media item. See below. |
|
||||||
|
|
||||||
|
If `url` is also specified, parsers/matchers are applied starting at that
|
||||||
|
folder, otherwise they are applied from the media source root (i.e.
|
||||||
|
`media-source://`).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
folders:
|
folders:
|
||||||
@@ -52,21 +62,93 @@ folders:
|
|||||||
# [...]
|
# [...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
?> To match everything at a given level whilst parsing nothing would simply be
|
||||||
| ---------- | ------- | ------------------------------------------------------ |
|
represented by an empty object `{}`
|
||||||
| `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
|
#### Matchers
|
||||||
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
|
Matches are used to match a given media item. Multiple matchers may be specified
|
||||||
|
to perform multiple tests. A given match may match multiple items. If an item
|
||||||
|
does not match, it will not be returned to the user nor (in case of subfolders)
|
||||||
|
feature in future traversals.
|
||||||
|
|
||||||
See [Folder Examples](../examples.md?id=folders).
|
##### Matcher: `title`
|
||||||
|
|
||||||
#### Understanding Media Source IDs and "parent folders"
|
Match against the media item title.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: title
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
| --------- | ---------------------------------------------------------------------- |
|
||||||
|
| `type` | Must be `title`. |
|
||||||
|
| `regexp` | An optional regular expression to matches against the title. |
|
||||||
|
| `title` | An optional exact value (case-sensitive) to matches against the title. |
|
||||||
|
|
||||||
|
#### Parsers
|
||||||
|
|
||||||
|
Parsers are used to extract data from a media item (e.g. an event start time).
|
||||||
|
Parsed data is propagated down the hierarcy, e.g. a given media item inherits
|
||||||
|
the metadata of its parents.
|
||||||
|
|
||||||
|
##### Parser `date` / `startdate`
|
||||||
|
|
||||||
|
Parses a start date from a media title. `date` is an convenient alias for
|
||||||
|
`startdate`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: date
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `type` | Must be `date` or `startdate`. |
|
||||||
|
| `format` | A [`date-fns` format string](https://date-fns.org/docs/parse). If unspecified, [`any-date-parser`](https://www.npmjs.com/package/any-date-parser) is used to parse a date and/or time which covers many common cases. In the event of missing or inaccurate metadata, specifying a precise format may help. |
|
||||||
|
| `regexp` | An optional regular expression to first match the title against before parsing. May be used to match against a subset of the string, see [tip below](#regular-expression-matching). |
|
||||||
|
|
||||||
|
## Advanced
|
||||||
|
|
||||||
|
### Regular Expression Matching
|
||||||
|
|
||||||
|
For `matchers` and `parsers` that support the `regexp` option, it may be used to
|
||||||
|
compare against only a portion of the media title. By default, that portion is
|
||||||
|
whatever part of the title matches the given regexp. For extra-precision, use a
|
||||||
|
named regexp group called `value`.
|
||||||
|
|
||||||
|
For example, pulling time values out of a media title, before parsing them with
|
||||||
|
a `HH:mm:ss` format.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
parsers:
|
||||||
|
- type: date
|
||||||
|
regexp: "\d{2}:\d{2}:\d{2}"
|
||||||
|
format: HH:mm:ss
|
||||||
|
```
|
||||||
|
|
||||||
|
Similarly, this example uses a named group to refer to the text at the end of
|
||||||
|
the title.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
parsers:
|
||||||
|
- type: date
|
||||||
|
regexp: '^Time: (?<value>.*)+'
|
||||||
|
format: HH:mm:ss
|
||||||
|
```
|
||||||
|
|
||||||
|
In this contrived example, the regular expression is used to extract either
|
||||||
|
`Low` or `High` from the title, and then match only the `High` entry.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
matchers:
|
||||||
|
- type: title
|
||||||
|
regexp: '(?<value>Low|High) Resolution'
|
||||||
|
title: High
|
||||||
|
```
|
||||||
|
|
||||||
|
### Understanding Media Source IDs and "parent folders"
|
||||||
|
|
||||||
Home Assistant Media Source IDs are typically long integration-specific non-user
|
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
|
friendly strings that refer to a media item, or folder of media items. Media
|
||||||
@@ -74,6 +156,93 @@ 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
|
rather a trail is built as the user navigates "downwards" -- but anything could
|
||||||
theoretically be the parent of anything.
|
theoretically be the parent of anything.
|
||||||
|
|
||||||
|
## Worked Example
|
||||||
|
|
||||||
|
Imagine a media folder hierarchy that starts with a choice of resolution (Low or
|
||||||
|
High). Lets start by specifying a basic folder referring to the URL of the Home
|
||||||
|
Assistant Media Browser for that folder (via copy and paste of the URL from
|
||||||
|
another browser window with the folder open):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
url: >-
|
||||||
|
/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0
|
||||||
|
```
|
||||||
|
|
||||||
|
The result:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Now lets include selecting the High resolution folder:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
url: >-
|
||||||
|
/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0
|
||||||
|
# Added below:
|
||||||
|
path:
|
||||||
|
- matchers:
|
||||||
|
- type: title
|
||||||
|
title: High resolution
|
||||||
|
```
|
||||||
|
|
||||||
|
The result:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The next step is to navigate down to the date folder, parsing the date as we go
|
||||||
|
(auto-detecting the format):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
url: >-
|
||||||
|
/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0
|
||||||
|
path:
|
||||||
|
- matchers:
|
||||||
|
- type: title
|
||||||
|
title: High resolution
|
||||||
|
# Added below:
|
||||||
|
- parsers:
|
||||||
|
- type: startdate
|
||||||
|
```
|
||||||
|
|
||||||
|
The result:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The final step is to navigate down to the media item themselves, automatically parsing the time out of them:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
url: >-
|
||||||
|
/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0
|
||||||
|
path:
|
||||||
|
- matchers:
|
||||||
|
- type: title
|
||||||
|
title: High resolution
|
||||||
|
- parsers:
|
||||||
|
- type: startdate
|
||||||
|
# Added below:
|
||||||
|
- parsers:
|
||||||
|
- type: startdate
|
||||||
|
```
|
||||||
|
|
||||||
|
The final result:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Other Examples
|
||||||
|
|
||||||
|
See [Folder Examples](../examples.md?id=folders).
|
||||||
|
|
||||||
## Fully expanded reference
|
## Fully expanded reference
|
||||||
|
|
||||||
[](common/expanded-warning.md ':include')
|
[](common/expanded-warning.md ':include')
|
||||||
@@ -85,7 +254,15 @@ folders:
|
|||||||
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
|
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
|
||||||
path:
|
path:
|
||||||
- id: 'media-source://'
|
- id: 'media-source://'
|
||||||
- title: 'Frigate'
|
- matchers:
|
||||||
- title_re: 'Clips.*'
|
- type: title
|
||||||
- title_re: 'Person.*'
|
regexp: (?<value>.*) resolution
|
||||||
|
title: Low
|
||||||
|
- parsers:
|
||||||
|
- type: date
|
||||||
|
format: yyyy/MM/dd
|
||||||
|
- parsers:
|
||||||
|
- type: startdate
|
||||||
|
format: HH:mm:ss
|
||||||
|
regexp: 'File (?<value>.*)'
|
||||||
```
|
```
|
||||||
|
|||||||
+40
-5
@@ -356,8 +356,8 @@ folders:
|
|||||||
### Folder within the Home Assistant default root
|
### Folder within the Home Assistant default root
|
||||||
|
|
||||||
This example applies a title match against the Home Assistant media root folder
|
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
|
looking for a folder entitled `Frigate`, and shows all items within it (since no
|
||||||
contents of that folder (if found).
|
matcher is specified at the lowest level).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:advanced-camera-card
|
type: custom:advanced-camera-card
|
||||||
@@ -367,7 +367,38 @@ folders:
|
|||||||
- type: ha
|
- type: ha
|
||||||
ha:
|
ha:
|
||||||
path:
|
path:
|
||||||
- title: 'Frigate'
|
- matchers:
|
||||||
|
- type: title
|
||||||
|
title: 'Frigate'
|
||||||
|
- {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Folder Parsing
|
||||||
|
|
||||||
|
This example parses dates from a folder, and times from the media items themselves.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:advanced-camera-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
folders:
|
||||||
|
- type: ha
|
||||||
|
ha:
|
||||||
|
url: >-
|
||||||
|
https://ha.ondu.org/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XHYTNH77WE3C654K03KX1F%7C0
|
||||||
|
path:
|
||||||
|
# Matches against the "Low resolution" folder.
|
||||||
|
- matchers:
|
||||||
|
- type: title
|
||||||
|
regexp: (?<value>.*) resolution
|
||||||
|
title: Low
|
||||||
|
# Parses the date out of the next level (auto-detected format).
|
||||||
|
- parsers:
|
||||||
|
- type: startdate
|
||||||
|
# Parses the time out of the items themselves (user-specified format).
|
||||||
|
- parsers:
|
||||||
|
- type: startdate
|
||||||
|
format: 'HH:mm:ss'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Folder URLs
|
### Folder URLs
|
||||||
@@ -387,8 +418,12 @@ folders:
|
|||||||
ha:
|
ha:
|
||||||
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
|
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate
|
||||||
path:
|
path:
|
||||||
- title_re: 'Clips.*'
|
- matchers:
|
||||||
- title_re: 'Person.*'
|
- type: title
|
||||||
|
regexp: 'Clips.*'
|
||||||
|
- matchers:
|
||||||
|
- type: title
|
||||||
|
regexp: 'Person.*'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Folder Paths
|
### Folder Paths
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -25,6 +25,7 @@
|
|||||||
"@lit-labs/scoped-registry-mixin": "^1.0.3",
|
"@lit-labs/scoped-registry-mixin": "^1.0.3",
|
||||||
"@lit-labs/task": "^1.1.3",
|
"@lit-labs/task": "^1.1.3",
|
||||||
"@types/bluebird": "^3.5.42",
|
"@types/bluebird": "^3.5.42",
|
||||||
|
"any-date-parser": "^2.2.0",
|
||||||
"component-emitter": "^1.3.1",
|
"component-emitter": "^1.3.1",
|
||||||
"compute-scroll-into-view": "^3.1.1",
|
"compute-scroll-into-view": "^3.1.1",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
cameraID: cameraID,
|
cameraID: cameraID,
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: parent?._metadata?.endDate ?? endOfDay(startDate),
|
endDate: parent?._metadata?.endDate ?? endOfDay(startDate),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -389,8 +389,8 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
engineOptions,
|
engineOptions,
|
||||||
);
|
);
|
||||||
for (const dayDirectory of directories ?? []) {
|
for (const dayDirectory of directories ?? []) {
|
||||||
if (dayDirectory._metadata) {
|
if (dayDirectory._metadata?.startDate) {
|
||||||
days.add(formatDate(dayDirectory._metadata?.startDate));
|
days.add(formatDate(dayDirectory._metadata.startDate));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
|||||||
import { orderBy } from 'lodash-es';
|
import { orderBy } from 'lodash-es';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
|
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
|
||||||
import { sortMediaByStartDate } from '../../ha/browse-media/sort-browse-media-by-start-date';
|
|
||||||
import {
|
import {
|
||||||
BROWSE_MEDIA_CACHE_SECONDS,
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
BrowseMedia,
|
BrowseMedia,
|
||||||
@@ -213,8 +212,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||||
media.can_expand &&
|
media.can_expand &&
|
||||||
isMediaWithinDates(media, matchOptions?.start, matchOptions?.end),
|
isMediaWithinDates(media, matchOptions?.start, matchOptions?.end),
|
||||||
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
|
||||||
sortMediaByStartDate(media),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
@@ -278,8 +275,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||||
!media.can_expand &&
|
!media.can_expand &&
|
||||||
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||||
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
|
||||||
sortMediaByStartDate(media),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
@@ -357,8 +352,8 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
for (const dayDirectory of directories ?? []) {
|
for (const dayDirectory of directories ?? []) {
|
||||||
/* istanbul ignore next: This situation cannot happen as the directory
|
/* istanbul ignore next: This situation cannot happen as the directory
|
||||||
will not match without metadata -- @preserve */
|
will not match without metadata -- @preserve */
|
||||||
if (dayDirectory._metadata) {
|
if (dayDirectory._metadata?.startDate) {
|
||||||
days.add(formatDate(dayDirectory._metadata?.startDate));
|
days.add(formatDate(dayDirectory._metadata.startDate));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
HAFolderPathComponent,
|
HAFolderPathComponent,
|
||||||
} from '../../../config/schema/folders';
|
} from '../../../config/schema/folders';
|
||||||
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
|
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
|
||||||
import { BrowseMedia, BrowseMediaCache } from '../../../ha/browse-media/types';
|
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
|
||||||
|
import {
|
||||||
|
BrowseMedia,
|
||||||
|
BrowseMediaCache,
|
||||||
|
BrowseMediaMetadata,
|
||||||
|
RichBrowseMedia,
|
||||||
|
} from '../../../ha/browse-media/types';
|
||||||
import {
|
import {
|
||||||
BrowseMediaStep,
|
BrowseMediaStep,
|
||||||
BrowseMediaTarget,
|
BrowseMediaTarget,
|
||||||
@@ -26,13 +32,24 @@ import {
|
|||||||
FolderQuery,
|
FolderQuery,
|
||||||
FoldersEngine,
|
FoldersEngine,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { MediaMatcher } from './media-matcher';
|
||||||
|
import { MetadataGenerator } from './metadata-generator.js';
|
||||||
|
|
||||||
export class HAFoldersEngine implements FoldersEngine {
|
export class HAFoldersEngine implements FoldersEngine {
|
||||||
private _browseMediaManager: BrowseMediaWalker;
|
private _browseMediaManager: BrowseMediaWalker;
|
||||||
private _cache = new BrowseMediaCache();
|
private _cache = new BrowseMediaCache<BrowseMediaMetadata>();
|
||||||
|
|
||||||
public constructor(browseMediaManager?: BrowseMediaWalker) {
|
private _metadataGenerator: MetadataGenerator;
|
||||||
this._browseMediaManager = browseMediaManager ?? new BrowseMediaWalker();
|
private _mediaMatcher: MediaMatcher;
|
||||||
|
|
||||||
|
public constructor(options?: {
|
||||||
|
browseMediaManager?: BrowseMediaWalker;
|
||||||
|
metadataGenerator?: MetadataGenerator;
|
||||||
|
mediaMatcher?: MediaMatcher;
|
||||||
|
}) {
|
||||||
|
this._browseMediaManager = options?.browseMediaManager ?? new BrowseMediaWalker();
|
||||||
|
this._metadataGenerator = options?.metadataGenerator ?? new MetadataGenerator();
|
||||||
|
this._mediaMatcher = options?.mediaMatcher ?? new MediaMatcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
|
||||||
@@ -75,6 +92,23 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDefaultFolderPathComponents(
|
||||||
|
haFolderConfig?: HAFolderConfig,
|
||||||
|
): NonEmptyTuple<FolderPathComponent> {
|
||||||
|
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id;
|
||||||
|
|
||||||
|
const path: HAFolderPathComponent[] = [
|
||||||
|
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
|
||||||
|
...(haFolderConfig?.url ?? []),
|
||||||
|
...(haFolderConfig?.path ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return path.map((component) => ({ ha: component })) as [
|
||||||
|
FolderPathComponent,
|
||||||
|
...FolderPathComponent[],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public async expandFolder(
|
public async expandFolder(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
query: FolderQuery,
|
query: FolderQuery,
|
||||||
@@ -89,11 +123,16 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
// Search through the path components from the start to find the last
|
// Search through the path components from the start to find the last
|
||||||
// component with a precise media source id, which is where the queries
|
// component with a precise media source id, which is where the queries
|
||||||
// start (and may drill down from).
|
// start (and may drill down from).
|
||||||
let start: string | null = null;
|
let start: string | RichBrowseMedia<BrowseMediaMetadata> | null = null;
|
||||||
while (pathComponents.length > 0) {
|
while (pathComponents.length > 0) {
|
||||||
const id = pathComponents[0]?.id;
|
const folderBrowseMedia =
|
||||||
if (id) {
|
pathComponents[0]?.folder instanceof BrowseMediaViewFolder
|
||||||
start = id;
|
? pathComponents[0].folder.getBrowseMedia()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const validStart = folderBrowseMedia ?? pathComponents[0]?.ha?.id ?? null;
|
||||||
|
if (validStart) {
|
||||||
|
start = validStart;
|
||||||
pathComponents.shift();
|
pathComponents.shift();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
@@ -106,37 +145,38 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This matcher matches a browse media against a given path component.
|
await this._metadataGenerator.prepare(
|
||||||
const componentMatcher = (
|
pathComponents.flatMap((component) => component.ha?.parsers ?? []),
|
||||||
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
|
// Generate a walk step, optionally matching against the next path component
|
||||||
// (if any), otherwise just returning all the media at this level.
|
// (if any), otherwise just returning all the media at this level.
|
||||||
const generateStep = (targets: BrowseMediaTarget[]): BrowseMediaStep[] => {
|
const generateStep = (
|
||||||
|
targets: BrowseMediaTarget<BrowseMediaMetadata>[],
|
||||||
|
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
||||||
const nextComponent = pathComponents.shift();
|
const nextComponent = pathComponents.shift();
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
targets,
|
targets,
|
||||||
|
metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) =>
|
||||||
|
this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers),
|
||||||
|
|
||||||
...(nextComponent && {
|
...(nextComponent && {
|
||||||
matcher: (media: BrowseMedia) => componentMatcher(media, nextComponent),
|
matcher: (media: BrowseMedia) =>
|
||||||
advance: (targets) => generateStep(targets),
|
this._mediaMatcher.match(
|
||||||
|
media,
|
||||||
|
nextComponent.ha?.matchers,
|
||||||
|
// Set foldersOnly to true if there are more stages in the path,
|
||||||
|
// as by definition only folders can be matched at this point.
|
||||||
|
pathComponents.length > 0,
|
||||||
|
),
|
||||||
|
advance: (targets) => (pathComponents.length ? generateStep(targets) : []),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const browseMedia = await this._browseMediaManager.walk(
|
const browseMedia = await this._browseMediaManager.walk<BrowseMediaMetadata>(
|
||||||
hass,
|
hass,
|
||||||
generateStep([start]),
|
generateStep([start]),
|
||||||
{
|
{
|
||||||
@@ -148,33 +188,4 @@ export class HAFoldersEngine implements FoldersEngine {
|
|||||||
folder: query.folder,
|
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Matcher } from '../../../config/schema/folders';
|
||||||
|
import { BrowseMedia } from '../../../ha/browse-media/types';
|
||||||
|
import { regexpExtract } from '../../../utils/regexp-extract';
|
||||||
|
import { REGEXP_GROUP_VALUE_KEY } from './types';
|
||||||
|
|
||||||
|
export class MediaMatcher {
|
||||||
|
public match(media: BrowseMedia, matchers?: Matcher[], foldersOnly = false): boolean {
|
||||||
|
if (foldersOnly && !media.can_expand) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const matcher of matchers ?? []) {
|
||||||
|
if (matcher.type === 'title') {
|
||||||
|
if (!this._matchTitle(matcher, media.title)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _matchTitle(matcher: Matcher, src: string): boolean {
|
||||||
|
const valueToMatch = matcher.regexp
|
||||||
|
? regexpExtract(matcher.regexp, src, { groupName: REGEXP_GROUP_VALUE_KEY })
|
||||||
|
: src;
|
||||||
|
|
||||||
|
if (!valueToMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matcher.title) {
|
||||||
|
return valueToMatch === matcher.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { parse } from 'date-fns';
|
||||||
|
import { Parser } from '../../../config/schema/folders';
|
||||||
|
import {
|
||||||
|
BrowseMedia,
|
||||||
|
BrowseMediaMetadata,
|
||||||
|
RichBrowseMedia,
|
||||||
|
} from '../../../ha/browse-media/types';
|
||||||
|
|
||||||
|
import parser from 'any-date-parser';
|
||||||
|
import { isValidDate } from '../../../utils/basic';
|
||||||
|
import { regexpExtract } from '../../../utils/regexp-extract';
|
||||||
|
import { REGEXP_GROUP_VALUE_KEY } from './types';
|
||||||
|
|
||||||
|
export class MetadataGenerator {
|
||||||
|
protected _anyDateParser: typeof parser | null = null;
|
||||||
|
|
||||||
|
public async prepare(parsers?: Parser[]): Promise<void> {
|
||||||
|
if (this._anyDateParser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically import the any-date-parser only if we have a parser that
|
||||||
|
// requires it, in order to save on bundle size.
|
||||||
|
if (
|
||||||
|
parsers?.some(
|
||||||
|
(parser) => ['date', 'startdate'].includes(parser.type) && !parser.format,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this._anyDateParser = (await import('any-date-parser')).default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public generate(
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
parsers?: Parser[],
|
||||||
|
): BrowseMediaMetadata | null {
|
||||||
|
// Always propagate metadata from parent to children.
|
||||||
|
const metadata: BrowseMediaMetadata = {
|
||||||
|
...parent?._metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const parser of parsers ?? []) {
|
||||||
|
const valueToParse = parser.regexp
|
||||||
|
? regexpExtract(parser.regexp, media.title, {
|
||||||
|
groupName: REGEXP_GROUP_VALUE_KEY,
|
||||||
|
})
|
||||||
|
: media.title;
|
||||||
|
if (!valueToParse) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parser.type === 'startdate' || parser.type === 'date') {
|
||||||
|
metadata.startDate =
|
||||||
|
this._parseDate(parser, valueToParse, parent?._metadata?.startDate) ??
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(metadata).length > 0 ? metadata : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseDate(parser: Parser, src: string, base?: Date): Date | undefined {
|
||||||
|
if (parser.format) {
|
||||||
|
return this._parseFormattedDate(parser.format, src, base);
|
||||||
|
}
|
||||||
|
return this._parseUnknownDate(src, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseFormattedDate(
|
||||||
|
format: string,
|
||||||
|
src: string,
|
||||||
|
base?: Date,
|
||||||
|
): Date | undefined {
|
||||||
|
const result = parse(src, format, base ?? new Date());
|
||||||
|
return isValidDate(result) ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseUnknownDate(src: string, base?: Date): Date | undefined {
|
||||||
|
if (!this._anyDateParser) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result = this._anyDateParser.attempt(src);
|
||||||
|
if (!Object.keys(result).length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this._anyDateParser.fromObject({
|
||||||
|
...(base && {
|
||||||
|
year: base.getFullYear(),
|
||||||
|
month: base.getMonth() + 1,
|
||||||
|
day: base.getDate(),
|
||||||
|
hour: base.getHours(),
|
||||||
|
minute: base.getMinutes(),
|
||||||
|
second: base.getSeconds(),
|
||||||
|
millisecond: base.getMilliseconds(),
|
||||||
|
}),
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const REGEXP_GROUP_VALUE_KEY = 'value';
|
||||||
@@ -4,7 +4,7 @@ import { ResolvedMediaCache } from '../../ha/resolved-media';
|
|||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { Endpoint } from '../../types';
|
import { Endpoint } from '../../types';
|
||||||
import { AdvancedCameraCardError } from '../../types.js';
|
import { AdvancedCameraCardError } from '../../types.js';
|
||||||
import { ViewItem } from '../../view/item';
|
import { ViewFolder, ViewItem } from '../../view/item';
|
||||||
import { ViewItemCapabilities } from '../../view/types';
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
|
|
||||||
// ====
|
// ====
|
||||||
@@ -22,8 +22,8 @@ export class FolderInitializationError extends AdvancedCameraCardError {}
|
|||||||
// ============
|
// ============
|
||||||
|
|
||||||
export type FolderPathComponent = {
|
export type FolderPathComponent = {
|
||||||
id?: string;
|
folder?: ViewFolder;
|
||||||
ha?: Omit<HAFolderPathComponent, 'id'>;
|
ha?: HAFolderPathComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FolderQuery {
|
export interface FolderQuery {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class FolderGalleryController {
|
|||||||
params: {
|
params: {
|
||||||
query: view.query.clone().setQuery({
|
query: view.query.clone().setQuery({
|
||||||
folder: rawQuery.folder,
|
folder: rawQuery.folder,
|
||||||
path: [...rawQuery.path, { id }],
|
path: [...rawQuery.path, { folder: item }],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,10 +135,9 @@ export class ThumbnailDetailsController {
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// To avoid duplication, if the event already has a structured 'what' and a
|
// To avoid duplication, if the event has a starttime, the title is omitted
|
||||||
// starttime, the title is omitted from the details.
|
// from the details.
|
||||||
const includeTitle =
|
const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime;
|
||||||
!ViewItemClassifier.isEvent(item) || !item?.getWhat()?.length || !startTime;
|
|
||||||
this._details = [
|
this._details = [
|
||||||
...(includeTitle && itemTitle
|
...(includeTitle && itemTitle
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -128,9 +128,8 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
|||||||
QueryClassifier.isFolderQuery(query) &&
|
QueryClassifier.isFolderQuery(query) &&
|
||||||
ViewItemClassifier.isFolder(item)
|
ViewItemClassifier.isFolder(item)
|
||||||
) {
|
) {
|
||||||
const id = item.getID();
|
|
||||||
const rawQuery = query.getQuery();
|
const rawQuery = query.getQuery();
|
||||||
if (!id || !rawQuery) {
|
if (!rawQuery) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +137,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement {
|
|||||||
params: {
|
params: {
|
||||||
query: query.clone().setQuery({
|
query: query.clone().setQuery({
|
||||||
folder: rawQuery.folder,
|
folder: rawQuery.folder,
|
||||||
path: [...(rawQuery.path ?? []), { id }],
|
path: [...(rawQuery.path ?? []), { folder: item }],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NonEmptyTuple } from 'type-fest';
|
import { NonEmptyTuple } from 'type-fest';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AdvancedCameraCardError } from '../../types';
|
|
||||||
import { isTruthy } from '../../utils/basic';
|
import { isTruthy } from '../../utils/basic';
|
||||||
import { regexSchema } from './common/regex';
|
import { regexSchema } from './common/regex';
|
||||||
|
import { AdvancedCameraCardError } from '../../types';
|
||||||
|
|
||||||
export const HA_MEDIA_SOURCE_ROOT = 'media-source://';
|
export const HA_MEDIA_SOURCE_ROOT = 'media-source://';
|
||||||
|
|
||||||
@@ -14,11 +14,35 @@ const folderConfigDefault = {
|
|||||||
ha: {},
|
ha: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parserBaseSchema = z.object({
|
||||||
|
regexp: regexSchema.optional(),
|
||||||
|
});
|
||||||
|
const startdateParserSchema = parserBaseSchema.extend({
|
||||||
|
type: z.literal('startdate'),
|
||||||
|
format: z.string().optional(),
|
||||||
|
});
|
||||||
|
// Simple alias date -> startdate.
|
||||||
|
const dateParserSchema = startdateParserSchema.extend({
|
||||||
|
type: z.literal('date'),
|
||||||
|
});
|
||||||
|
const parserSchema = z.discriminatedUnion('type', [
|
||||||
|
dateParserSchema,
|
||||||
|
startdateParserSchema,
|
||||||
|
]);
|
||||||
|
export type Parser = z.infer<typeof parserSchema>;
|
||||||
|
|
||||||
|
const titleMatcherSchema = parserBaseSchema.extend({
|
||||||
|
type: z.literal('title'),
|
||||||
|
regexp: regexSchema.optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
});
|
||||||
|
const matcherSchema = z.discriminatedUnion('type', [titleMatcherSchema]);
|
||||||
|
export type Matcher = z.infer<typeof matcherSchema>;
|
||||||
|
|
||||||
const haFolderPathComponentSchema = z.object({
|
const haFolderPathComponentSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
|
parsers: parserSchema.array().optional(),
|
||||||
title: z.string().optional(),
|
matchers: matcherSchema.array().optional(),
|
||||||
title_re: regexSchema.optional(),
|
|
||||||
});
|
});
|
||||||
export type HAFolderPathComponent = z.infer<typeof haFolderPathComponentSchema>;
|
export type HAFolderPathComponent = z.infer<typeof haFolderPathComponentSchema>;
|
||||||
|
|
||||||
@@ -50,9 +74,7 @@ export const transformPathURLToPathArray = (
|
|||||||
|
|
||||||
for (const component of folderPath) {
|
for (const component of folderPath) {
|
||||||
if (component.id && !component.id.startsWith(HA_MEDIA_SOURCE_ROOT)) {
|
if (component.id && !component.id.startsWith(HA_MEDIA_SOURCE_ROOT)) {
|
||||||
throw new AdvancedCameraCardError(
|
throw new AdvancedCameraCardError(`Could not parse media source URL: ${url}`);
|
||||||
`Could not parse valid media source URL: ${url}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return folderPath;
|
return folderPath;
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ export const getViewMediaFromBrowseMediaArray = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getViewItemsFromBrowseMediaArray = <
|
export const getViewItemsFromBrowseMediaArray = <M extends BrowseMediaMetadata>(
|
||||||
M extends BrowseMediaMetadata | undefined,
|
|
||||||
>(
|
|
||||||
browseMedia: RichBrowseMedia<M>[],
|
browseMedia: RichBrowseMedia<M>[],
|
||||||
options?: ViewMediaSourceOptions,
|
options?: ViewMediaSourceOptions,
|
||||||
): ViewItem[] => {
|
): ViewItem[] => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
export class BrowseMediaViewItemFactory {
|
export class BrowseMediaViewItemFactory {
|
||||||
static create(
|
static create(
|
||||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata | undefined>,
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
options?: ViewMediaSourceOptions,
|
options?: ViewMediaSourceOptions,
|
||||||
): ViewItem | null {
|
): ViewItem | null {
|
||||||
if (browseMedia.can_expand) {
|
if (browseMedia.can_expand) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
ViewMediaSourceOptions,
|
ViewMediaSourceOptions,
|
||||||
ViewMediaType,
|
ViewMediaType,
|
||||||
} from '../../view/item';
|
} from '../../view/item';
|
||||||
import { BrowseMedia, BrowseMediaMetadata, RichBrowseMedia } from './types';
|
import { BrowseMediaMetadata, RichBrowseMedia } from './types';
|
||||||
|
|
||||||
interface MediaClassBrowserSetting {
|
interface MediaClassBrowserSetting {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -114,12 +114,19 @@ export class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMed
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BrowseMediaViewFolder extends ViewFolder {
|
export class BrowseMediaViewFolder extends ViewFolder {
|
||||||
constructor(folder: FolderConfig, browseMedia: BrowseMedia) {
|
private _browseMedia: RichBrowseMedia<BrowseMediaMetadata>;
|
||||||
|
|
||||||
|
constructor(folder: FolderConfig, browseMedia: RichBrowseMedia<BrowseMediaMetadata>) {
|
||||||
super(folder, {
|
super(folder, {
|
||||||
id: browseMedia.media_content_id,
|
id: browseMedia.media_content_id,
|
||||||
icon: getIcon(browseMedia.children_media_class ?? browseMedia.media_class),
|
icon: getIcon(browseMedia.children_media_class ?? browseMedia.media_class),
|
||||||
title: browseMedia.title,
|
title: browseMedia.title,
|
||||||
thumbnail: browseMedia.thumbnail,
|
thumbnail: browseMedia.thumbnail,
|
||||||
});
|
});
|
||||||
|
this._browseMedia = browseMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBrowseMedia(): RichBrowseMedia<BrowseMediaMetadata> {
|
||||||
|
return this._browseMedia;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { orderBy } from 'lodash-es';
|
|
||||||
import { BrowseMediaMetadata, RichBrowseMedia } from './types';
|
|
||||||
|
|
||||||
export const sortMediaByStartDate = (
|
|
||||||
media: RichBrowseMedia<BrowseMediaMetadata>[],
|
|
||||||
): RichBrowseMedia<BrowseMediaMetadata>[] => {
|
|
||||||
return orderBy(media, (media) => media._metadata?.startDate, 'desc');
|
|
||||||
};
|
|
||||||
@@ -2,9 +2,9 @@ import { z } from 'zod';
|
|||||||
import { ExpiringEqualityCache } from '../../cache/expiring-cache';
|
import { ExpiringEqualityCache } from '../../cache/expiring-cache';
|
||||||
|
|
||||||
export interface BrowseMediaMetadata {
|
export interface BrowseMediaMetadata {
|
||||||
cameraID: string;
|
cameraID?: string;
|
||||||
startDate: Date;
|
startDate?: Date;
|
||||||
endDate: Date;
|
endDate?: Date;
|
||||||
what?: string[];
|
what?: string[];
|
||||||
}
|
}
|
||||||
// Recursive type, cannot use type interference:
|
// Recursive type, cannot use type interference:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const isMediaWithinDates = (
|
|||||||
end?: Date,
|
end?: Date,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// If there's no metadata, nothing matches.
|
// If there's no metadata, nothing matches.
|
||||||
if (!media._metadata) {
|
if (!media._metadata?.startDate || !media._metadata?.endDate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Extracts a substring from a string using a regular expression pattern, if
|
||||||
|
* groupName / groupNumber is specified but not found in the result, the full
|
||||||
|
* match is returned.
|
||||||
|
*/
|
||||||
|
export const regexpExtract = (
|
||||||
|
pattern: string | RegExp,
|
||||||
|
val: string,
|
||||||
|
options?: {
|
||||||
|
groupName?: string;
|
||||||
|
groupNumber?: number;
|
||||||
|
},
|
||||||
|
): string | null => {
|
||||||
|
const match = val.match(pattern);
|
||||||
|
if (options?.groupName && match?.groups?.[options.groupName]) {
|
||||||
|
return match.groups[options.groupName];
|
||||||
|
}
|
||||||
|
if (options?.groupNumber !== undefined && match?.[options.groupNumber]) {
|
||||||
|
return match[options.groupNumber];
|
||||||
|
}
|
||||||
|
return match ? match[0] : null;
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine';
|
import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine';
|
||||||
import { FolderQuery } from '../../../../src/card-controller/folders/types';
|
import { FolderQuery } from '../../../../src/card-controller/folders/types';
|
||||||
import { FolderConfig } from '../../../../src/config/schema/folders';
|
import { FolderConfig } from '../../../../src/config/schema/folders';
|
||||||
|
import { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item';
|
||||||
|
import { browseMediaSchema } from '../../../../src/ha/browse-media/types';
|
||||||
import { getMediaDownloadPath } from '../../../../src/ha/download';
|
import { getMediaDownloadPath } from '../../../../src/ha/download';
|
||||||
import { homeAssistantWSRequest } from '../../../../src/ha/ws-request';
|
import { homeAssistantWSRequest } from '../../../../src/ha/ws-request';
|
||||||
import { Endpoint } from '../../../../src/types';
|
import { Endpoint } from '../../../../src/types';
|
||||||
@@ -73,17 +75,6 @@ describe('HAFoldersEngine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('expandFolder', () => {
|
|
||||||
it('should reject folders of the wrong type', async () => {
|
|
||||||
const query = {
|
|
||||||
folder: { type: 'UNKNOWN' },
|
|
||||||
} as unknown as FolderQuery;
|
|
||||||
const engine = new HAFoldersEngine();
|
|
||||||
|
|
||||||
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should generate default folder query', () => {
|
describe('should generate default folder query', () => {
|
||||||
it('should generate default folder query', () => {
|
it('should generate default folder query', () => {
|
||||||
const folder: FolderConfig = { type: 'ha' };
|
const folder: FolderConfig = { type: 'ha' };
|
||||||
@@ -92,7 +83,7 @@ describe('HAFoldersEngine', () => {
|
|||||||
const query = engine.generateDefaultFolderQuery(folder);
|
const query = engine.generateDefaultFolderQuery(folder);
|
||||||
expect(query).toEqual({
|
expect(query).toEqual({
|
||||||
folder,
|
folder,
|
||||||
path: [{ id: 'media-source://', ha: { id: 'media-source://' } }],
|
path: [{ ha: { id: 'media-source://' } }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,10 +104,7 @@ describe('HAFoldersEngine', () => {
|
|||||||
const engine = new HAFoldersEngine();
|
const engine = new HAFoldersEngine();
|
||||||
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
|
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
|
||||||
folder,
|
folder,
|
||||||
path: [
|
path: [{ ha: { id: 'media-source://1' } }, { ha: { id: 'media-source://2' } }],
|
||||||
{ id: 'media-source://1', ha: { id: 'media-source://1' } },
|
|
||||||
{ id: 'media-source://2', ha: { id: 'media-source://2' } },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,28 +113,39 @@ describe('HAFoldersEngine', () => {
|
|||||||
const engine = new HAFoldersEngine();
|
const engine = new HAFoldersEngine();
|
||||||
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
|
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
|
||||||
folder,
|
folder,
|
||||||
path: [{ id: 'media-source://', ha: { id: 'media-source://' } }],
|
path: [{ ha: { id: 'media-source://' } }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add default media root as necessary', async () => {
|
it('should add default media root as necessary', async () => {
|
||||||
const folder = createFolder({ ha: { path: [{ title: 'Frigate' }] } });
|
const folder = createFolder({
|
||||||
|
ha: { path: [{ matchers: [{ type: 'title', title: 'Frigate' }] }] },
|
||||||
|
});
|
||||||
const engine = new HAFoldersEngine();
|
const engine = new HAFoldersEngine();
|
||||||
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
|
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
|
||||||
folder,
|
folder,
|
||||||
path: [
|
path: [
|
||||||
{ id: 'media-source://', ha: { id: 'media-source://' } },
|
{ ha: { id: 'media-source://' } },
|
||||||
{ id: undefined, ha: { title: 'Frigate' } },
|
{ ha: { matchers: [{ type: 'title', title: 'Frigate' }] } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should expand folder', () => {
|
describe('should expand folder', () => {
|
||||||
|
it('should reject folders of the wrong type', async () => {
|
||||||
|
const query = {
|
||||||
|
folder: { type: 'UNKNOWN' },
|
||||||
|
} as unknown as FolderQuery;
|
||||||
|
const engine = new HAFoldersEngine();
|
||||||
|
|
||||||
|
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('should expand folder with cache by default', async () => {
|
it('should expand folder with cache by default', async () => {
|
||||||
const query: FolderQuery = {
|
const query: FolderQuery = {
|
||||||
folder: { type: 'ha' },
|
folder: { type: 'ha' },
|
||||||
path: [{ id: 'media-source://id' }],
|
path: [{ ha: { id: 'media-source://id' } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(
|
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(
|
||||||
@@ -183,7 +182,7 @@ describe('HAFoldersEngine', () => {
|
|||||||
it('should expand folder without cache when requested', async () => {
|
it('should expand folder without cache when requested', async () => {
|
||||||
const query: FolderQuery = {
|
const query: FolderQuery = {
|
||||||
folder: { type: 'ha' },
|
folder: { type: 'ha' },
|
||||||
path: [{ id: 'media-source://id' }],
|
path: [{ ha: { id: 'media-source://id' } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(homeAssistantWSRequest)
|
vi.mocked(homeAssistantWSRequest)
|
||||||
@@ -221,150 +220,93 @@ describe('HAFoldersEngine', () => {
|
|||||||
expect(homeAssistantWSRequest).toBeCalledTimes(2);
|
expect(homeAssistantWSRequest).toBeCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use id from browsemedia in folder in query', async () => {
|
||||||
|
const browseMedia = createBrowseMedia({
|
||||||
|
media_content_id: 'media-source://id',
|
||||||
|
can_expand: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const query: FolderQuery = {
|
||||||
|
folder: { type: 'ha' },
|
||||||
|
path: [
|
||||||
|
{
|
||||||
|
folder: new BrowseMediaViewFolder(createFolder(), browseMedia),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(
|
||||||
|
createBrowseMedia({
|
||||||
|
media_content_id: 'media-source://id',
|
||||||
|
can_expand: true,
|
||||||
|
children: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hass = createHASS();
|
||||||
|
const engine = new HAFoldersEngine();
|
||||||
|
await engine.expandFolder(hass, query);
|
||||||
|
|
||||||
|
expect(homeAssistantWSRequest).toBeCalledWith(hass, browseMediaSchema, {
|
||||||
|
type: 'media_source/browse_media',
|
||||||
|
media_content_id: 'media-source://id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not expand without a folder with an id', async () => {
|
it('should not expand without a folder with an id', async () => {
|
||||||
const query: FolderQuery = {
|
const query: FolderQuery = {
|
||||||
folder: { type: 'ha' },
|
folder: { type: 'ha' },
|
||||||
// There's no component in the query with an id to start from.
|
// There's no component in the query with an id to start from.
|
||||||
path: [{ ha: { title: 'Frigate' } }],
|
path: [{ ha: {} }],
|
||||||
};
|
};
|
||||||
const engine = new HAFoldersEngine();
|
const engine = new HAFoldersEngine();
|
||||||
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
|
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should expand folder with title based query', async () => {
|
describe('should apply matchers', async () => {
|
||||||
const query: FolderQuery = {
|
it('should expand folder with title based query', async () => {
|
||||||
folder: { type: 'ha' },
|
const query: FolderQuery = {
|
||||||
path: [{ id: 'media-source://' }, { ha: { title: 'Frigate' } }],
|
folder: { type: 'ha' },
|
||||||
};
|
path: [
|
||||||
|
{ ha: { id: 'media-source://' } },
|
||||||
|
{ ha: { matchers: [{ type: 'title', title: 'Frigate' }] } },
|
||||||
|
{},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
vi.mocked(homeAssistantWSRequest)
|
vi.mocked(homeAssistantWSRequest)
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
createBrowseMedia({
|
createBrowseMedia({
|
||||||
media_content_id: 'media-source://',
|
media_content_id: 'media-source://',
|
||||||
can_expand: true,
|
can_expand: true,
|
||||||
children: [
|
children: [
|
||||||
createBrowseMedia({
|
createBrowseMedia({
|
||||||
media_content_id: 'media-source://frigate',
|
media_content_id: 'media-source://frigate',
|
||||||
title: 'Frigate',
|
title: 'Frigate',
|
||||||
can_expand: true,
|
can_expand: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
createBrowseMedia({
|
createBrowseMedia({
|
||||||
media_content_id: 'media-source://frigate',
|
media_content_id: 'media-source://frigate',
|
||||||
can_expand: true,
|
can_expand: true,
|
||||||
children: [
|
children: [
|
||||||
createBrowseMedia({
|
createBrowseMedia({
|
||||||
media_content_id: 'media-source://frigate/result',
|
media_content_id: 'media-source://frigate/result',
|
||||||
title: 'Result',
|
title: 'Result',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const engine = new HAFoldersEngine();
|
const engine = new HAFoldersEngine();
|
||||||
const results = await engine.expandFolder(createHASS(), query);
|
const results = await engine.expandFolder(createHASS(), query);
|
||||||
expect(results?.length).toBe(1);
|
expect(results?.length).toBe(1);
|
||||||
expect(results?.[0]).toBeInstanceOf(ViewMedia);
|
expect(results?.[0]).toBeInstanceOf(ViewMedia);
|
||||||
expect(results?.[0].getID()).toBe('media-source://frigate/result');
|
expect(results?.[0].getID()).toBe('media-source://frigate/result');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should expand folder with title_re based query', async () => {
|
|
||||||
const query: FolderQuery = {
|
|
||||||
folder: { type: 'ha' },
|
|
||||||
path: [{ id: 'media-source://' }, { ha: { title_re: 'Fri.*' } }],
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(homeAssistantWSRequest)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://',
|
|
||||||
can_expand: true,
|
|
||||||
children: [
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate',
|
|
||||||
title: 'Frigate',
|
|
||||||
can_expand: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate',
|
|
||||||
can_expand: true,
|
|
||||||
children: [
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate/result',
|
|
||||||
title: 'Result',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const engine = new HAFoldersEngine();
|
|
||||||
const results = await engine.expandFolder(createHASS(), query);
|
|
||||||
expect(results?.length).toBe(1);
|
|
||||||
expect(results?.[0]).toBeInstanceOf(ViewMedia);
|
|
||||||
expect(results?.[0].getID()).toBe('media-source://frigate/result');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should expand folder with ha id based query', async () => {
|
|
||||||
const query: FolderQuery = {
|
|
||||||
folder: { type: 'ha' },
|
|
||||||
path: [
|
|
||||||
{ id: 'media-source://' },
|
|
||||||
{ ha: { title: 'Frigate' } },
|
|
||||||
{ id: 'media-source://frigate/subdir' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(homeAssistantWSRequest)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://',
|
|
||||||
can_expand: true,
|
|
||||||
children: [
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate',
|
|
||||||
title: 'Frigate',
|
|
||||||
can_expand: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate',
|
|
||||||
can_expand: true,
|
|
||||||
children: [
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate/subdir',
|
|
||||||
can_expand: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate/subdir',
|
|
||||||
can_expand: true,
|
|
||||||
children: [
|
|
||||||
createBrowseMedia({
|
|
||||||
media_content_id: 'media-source://frigate/subdir/result',
|
|
||||||
title: 'Result',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const engine = new HAFoldersEngine();
|
|
||||||
const results = await engine.expandFolder(createHASS(), query);
|
|
||||||
expect(results?.length).toBe(1);
|
|
||||||
expect(results?.[0]).toBeInstanceOf(ViewMedia);
|
|
||||||
expect(results?.[0].getID()).toBe('media-source://frigate/subdir/result');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MediaMatcher } from '../../../../src/card-controller/folders/ha/media-matcher';
|
||||||
|
import { Matcher } from '../../../../src/config/schema/folders';
|
||||||
|
import { BrowseMedia } from '../../../../src/ha/browse-media/types';
|
||||||
|
|
||||||
|
describe('MediaMatcher', () => {
|
||||||
|
describe('match', () => {
|
||||||
|
const createMediaItem = (
|
||||||
|
title: string,
|
||||||
|
can_expand: boolean,
|
||||||
|
media_class = 'image',
|
||||||
|
): BrowseMedia => ({
|
||||||
|
title,
|
||||||
|
media_class,
|
||||||
|
media_content_type: media_class === 'directory' ? 'directory' : 'image/jpeg',
|
||||||
|
media_content_id: `${media_class}_${title.replace(/\s+/g, '_')}`,
|
||||||
|
can_play: media_class !== 'directory',
|
||||||
|
can_expand,
|
||||||
|
thumbnail: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if foldersOnly is true and media.can_expand is false', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Test File', false);
|
||||||
|
expect(mediaMatcher.match(media, [], true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if foldersOnly is true and media.can_expand is true', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Test Folder', true, 'directory');
|
||||||
|
expect(mediaMatcher.match(media, [], true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if matchers array is empty', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Test Media', false);
|
||||||
|
expect(mediaMatcher.match(media, [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if matchers array is undefined', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Test Media', false);
|
||||||
|
expect(mediaMatcher.match(media, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with title matcher', () => {
|
||||||
|
it('should return true when title matches exactly', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Exact Title', false);
|
||||||
|
const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when title does not match exactly', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('DOES NOT MATCH', false);
|
||||||
|
const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when title matches regexp and extracted value matches matcher.title', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Prefix-ImportantPart-Suffix', false);
|
||||||
|
const matchers: Matcher[] = [
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
regexp: '^Prefix-(?<value>ImportantPart)-Suffix$',
|
||||||
|
title: 'ImportantPart',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when title matches regexp but extracted value does not match matcher.title', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Prefix-ImportantPart-Suffix', false);
|
||||||
|
const matchers: Matcher[] = [
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
regexp: '^Prefix-(?<value>ImportantPart)-Suffix$',
|
||||||
|
title: 'WrongPart',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when title matches regexp with an explicit title value', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Prefix-ImportantPart-Suffix', false);
|
||||||
|
const matchers: Matcher[] = [
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
regexp: '^Prefix-(?<value>ImportantPart)-Suffix$',
|
||||||
|
// title is undefined.
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when title does not match regexp', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Unrelated Title', false);
|
||||||
|
const matchers: Matcher[] = [
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
regexp: '^Prefix-(?<value>ImportantPart)-Suffix$',
|
||||||
|
title: 'ImportantPart',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when regexp is provided but does not extract the required group', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Prefix-ImportantPart-Suffix', false);
|
||||||
|
const matchers: Matcher[] = [
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
regexp: `^Prefix-ImportantPart-Suffix$`, // No named group
|
||||||
|
title: 'ImportantPart',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when no regexp and no matcher.title (matches any title)', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Any Title Will Do', false);
|
||||||
|
const matchers: Matcher[] = [{ type: 'title' }];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if one of multiple matchers fails', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Test Media One', false);
|
||||||
|
const matchers: Matcher[] = [
|
||||||
|
{ type: 'title', title: 'Test Media One' }, // Pass
|
||||||
|
{ type: 'title', title: 'Test Media Two' }, // Fail
|
||||||
|
];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true if all multiple matchers pass', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Test Media One', false);
|
||||||
|
const matchers: Matcher[] = [
|
||||||
|
{ type: 'title', title: 'Test Media One' },
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
regexp: `^(?<value>Test Media One)$`,
|
||||||
|
title: 'Test Media One',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore matchers of unknown types', () => {
|
||||||
|
const mediaMatcher = new MediaMatcher();
|
||||||
|
const media = createMediaItem('Test Media', false);
|
||||||
|
|
||||||
|
const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }];
|
||||||
|
expect(mediaMatcher.match(media, matchers)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MetadataGenerator } from '../../../../src/card-controller/folders/ha/metadata-generator';
|
||||||
|
import { Parser } from '../../../../src/config/schema/folders';
|
||||||
|
import { createBrowseMedia, createRichBrowseMedia } from '../../../test-utils';
|
||||||
|
|
||||||
|
describe('MetadataGenerator', () => {
|
||||||
|
const browseMedia = createBrowseMedia({
|
||||||
|
title: 'Test Media 2025-05-26 18:18',
|
||||||
|
});
|
||||||
|
const expectedDate = new Date('2025-05-26T18:18:00.000Z');
|
||||||
|
const formatlessDateParser: Parser = {
|
||||||
|
type: 'startdate',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('should dynamically import any-date-parser when needed', () => {
|
||||||
|
it('should not import any-date-parser if no parsers require it', async () => {
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([
|
||||||
|
// Pretend no parsers require unknown date formats.
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import any-date-parser if a parser requires it', async () => {
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
|
||||||
|
await generator.prepare([formatlessDateParser]);
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate,
|
||||||
|
).toEqual(expectedDate);
|
||||||
|
|
||||||
|
// Re-preparing should nothing.
|
||||||
|
await generator.prepare([formatlessDateParser]);
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate,
|
||||||
|
).toEqual(expectedDate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should generate metadata', () => {
|
||||||
|
it('should not generate metadata without an parsers', async () => {
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([]);
|
||||||
|
|
||||||
|
expect(generator.generate(browseMedia)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore unknown parsers', async () => {
|
||||||
|
const goodParser: Parser = {
|
||||||
|
type: 'date',
|
||||||
|
};
|
||||||
|
const badParser: Parser = {
|
||||||
|
type: 'UNKNOWN' as 'date',
|
||||||
|
};
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([goodParser, badParser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [goodParser, badParser]),
|
||||||
|
).toEqual({
|
||||||
|
startDate: expectedDate,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should generate date metadata', () => {
|
||||||
|
describe('should generate date metadata without a date format', () => {
|
||||||
|
it('should generate start date without a date format', async () => {
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([formatlessDateParser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [formatlessDateParser])
|
||||||
|
?.startDate,
|
||||||
|
).toEqual(expectedDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat date as an alias for the startdate parser', async () => {
|
||||||
|
const parser: Parser = {
|
||||||
|
type: 'date',
|
||||||
|
};
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([parser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [parser])?.startDate,
|
||||||
|
).toEqual(expectedDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not parse when regexp fails to match', async () => {
|
||||||
|
const parser: Parser = {
|
||||||
|
type: 'date',
|
||||||
|
regexp: 'WILL_NOT_MATCH',
|
||||||
|
};
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([parser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [parser])?.startDate,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to generate start date without a date format', async () => {
|
||||||
|
const badBrowseMedia = createBrowseMedia({
|
||||||
|
title: 'Test Media NO DATE',
|
||||||
|
});
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([formatlessDateParser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(badBrowseMedia, undefined, [formatlessDateParser])
|
||||||
|
?.startDate,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should incorporate parent metadata without a date format', async () => {
|
||||||
|
const parentBrowseMedia = createRichBrowseMedia({
|
||||||
|
title: '2025-05-26',
|
||||||
|
_metadata: {
|
||||||
|
startDate: new Date('2025-05-26T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const childBrowseMedia = createBrowseMedia({
|
||||||
|
title: '22:42',
|
||||||
|
});
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([formatlessDateParser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(childBrowseMedia, parentBrowseMedia, [
|
||||||
|
formatlessDateParser,
|
||||||
|
])?.startDate,
|
||||||
|
).toEqual(new Date('2025-05-26T22:42:00.000Z'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should generate date metadata with a date format', () => {
|
||||||
|
it('should generate start date with a date formater and a regexp', async () => {
|
||||||
|
const dateParser: Parser = {
|
||||||
|
type: 'startdate',
|
||||||
|
format: 'yyyy-MM-dd HH:mm',
|
||||||
|
regexp: 'Test Media (?<value>.*)',
|
||||||
|
};
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([dateParser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [dateParser])?.startDate,
|
||||||
|
).toEqual(expectedDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to generate start date with a date formater without a regexp', async () => {
|
||||||
|
const dateParser: Parser = {
|
||||||
|
type: 'startdate',
|
||||||
|
format: 'yyyy-MM-dd HH:mm',
|
||||||
|
};
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([dateParser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(browseMedia, undefined, [dateParser])?.startDate,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should incorporate parent metadata with a date format', async () => {
|
||||||
|
const parentBrowseMedia = createRichBrowseMedia({
|
||||||
|
title: '2025-05-26',
|
||||||
|
_metadata: {
|
||||||
|
startDate: new Date('2025-05-26T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const childBrowseMedia = createBrowseMedia({
|
||||||
|
title: '22:42',
|
||||||
|
});
|
||||||
|
const parser: Parser = {
|
||||||
|
type: 'startdate',
|
||||||
|
format: 'HH:mm',
|
||||||
|
};
|
||||||
|
const generator = new MetadataGenerator();
|
||||||
|
await generator.prepare([parser]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
generator.generate(childBrowseMedia, parentBrowseMedia, [parser])?.startDate,
|
||||||
|
).toEqual(new Date('2025-05-26T22:42:00.000Z'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -161,7 +161,7 @@ describe('FolderGalleryController', () => {
|
|||||||
}),
|
}),
|
||||||
query: new FolderViewQuery({
|
query: new FolderViewQuery({
|
||||||
folder,
|
folder,
|
||||||
path: [{ id: 'grandparent' }],
|
path: [{ ha: { id: 'grandparent' } }],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ describe('FolderGalleryController', () => {
|
|||||||
expect(newQuery).toBeInstanceOf(FolderViewQuery);
|
expect(newQuery).toBeInstanceOf(FolderViewQuery);
|
||||||
expect(newQuery?.getQuery()).toEqual({
|
expect(newQuery?.getQuery()).toEqual({
|
||||||
folder,
|
folder,
|
||||||
path: [{ id: 'grandparent' }, { id: folderItem.getID() }],
|
path: [{ ha: { id: 'grandparent' } }, { folder: folderItem }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ describe('FolderGalleryController', () => {
|
|||||||
const view = createView({
|
const view = createView({
|
||||||
query: new FolderViewQuery({
|
query: new FolderViewQuery({
|
||||||
folder,
|
folder,
|
||||||
path: [{ id: 'id' }],
|
path: [{ ha: { id: 'id' } }],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const viewManager = mock<ViewManagerInterface>();
|
const viewManager = mock<ViewManagerInterface>();
|
||||||
|
|||||||
@@ -118,10 +118,9 @@ describe('ThumbnailDetailsController', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not have title with a what and a start time', () => {
|
it('should not have title with a start time', () => {
|
||||||
const item = new TestViewMedia({
|
const item = new TestViewMedia({
|
||||||
title: 'Test Event',
|
title: 'Test Event',
|
||||||
what: ['person', 'car'],
|
|
||||||
startTime: new Date('2025-05-22T21:12:00Z'),
|
startTime: new Date('2025-05-22T21:12:00Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe('transformURLToMediaSourceRoot', () => {
|
|||||||
it.each(prefixes)('with prefix %s', (urlPrefix: string) => {
|
it.each(prefixes)('with prefix %s', (urlPrefix: string) => {
|
||||||
const url = `${urlPrefix}media-browser/browser,does-not-start-with-media-source`;
|
const url = `${urlPrefix}media-browser/browser,does-not-start-with-media-source`;
|
||||||
expect(() => transformPathURLToPathArray(url)).toThrowError(
|
expect(() => transformPathURLToPathArray(url)).toThrowError(
|
||||||
/Could not parse valid media source URL/,
|
/Could not parse media source URL/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { sortMediaByStartDate } from '../../src/ha/browse-media/sort-browse-media-by-start-date';
|
|
||||||
import { BrowseMediaMetadata, RichBrowseMedia } from '../../src/ha/browse-media/types';
|
|
||||||
import { createBrowseMedia, createRichBrowseMedia } from '../test-utils';
|
|
||||||
|
|
||||||
const createMetadata = (
|
|
||||||
metadata: Partial<BrowseMediaMetadata>,
|
|
||||||
): BrowseMediaMetadata => ({
|
|
||||||
cameraID: 'camera.office',
|
|
||||||
startDate: new Date('2025-05-10T20:22:00Z'),
|
|
||||||
endDate: new Date('2025-05-10T20:22:10Z'),
|
|
||||||
...metadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sortMediaByStartDate', () => {
|
|
||||||
it('should return an empty array when given an empty array', () => {
|
|
||||||
const media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
|
|
||||||
expect(sortMediaByStartDate(media)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should sort media by startDate in descending order', () => {
|
|
||||||
const media: RichBrowseMedia<BrowseMediaMetadata>[] = [
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 1',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 2',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 3',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
const expected: RichBrowseMedia<BrowseMediaMetadata>[] = [
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 2',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 3',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 1',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
expect(sortMediaByStartDate(media)).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle items with undefined startDate by placing them at the end', () => {
|
|
||||||
const media: RichBrowseMedia<BrowseMediaMetadata>[] = [
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 1',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
createBrowseMedia({
|
|
||||||
title: 'Media 2',
|
|
||||||
}),
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 3',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
createBrowseMedia({
|
|
||||||
title: 'Media 4',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
const expected: RichBrowseMedia<BrowseMediaMetadata>[] = [
|
|
||||||
createBrowseMedia({
|
|
||||||
title: 'Media 2',
|
|
||||||
}),
|
|
||||||
createBrowseMedia({
|
|
||||||
title: 'Media 4',
|
|
||||||
}),
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 3',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
createRichBrowseMedia({
|
|
||||||
title: 'Media 1',
|
|
||||||
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(sortMediaByStartDate(media)).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { regexpExtract } from '../../src/utils/regexp-extract';
|
||||||
|
|
||||||
|
describe('regexpExtract', () => {
|
||||||
|
it('returns null if no match is found', () => {
|
||||||
|
expect(regexpExtract(/foo/, 'bar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the full match if no group is specified', () => {
|
||||||
|
expect(regexpExtract(/foo/, 'foo bar')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the named group if groupName is specified', () => {
|
||||||
|
const pattern = /(?<first>\w+)\s(?<second>\w+)/;
|
||||||
|
expect(regexpExtract(pattern, 'hello world', { groupName: 'first' })).toBe('hello');
|
||||||
|
expect(regexpExtract(pattern, 'hello world', { groupName: 'second' })).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the numbered group if groupNumber is specified', () => {
|
||||||
|
expect(regexpExtract(/(\d+)-(\w+)/, '123-abc', { groupNumber: 1 })).toBe('123');
|
||||||
|
expect(regexpExtract(/(\d+)-(\w+)/, '123-abc', { groupNumber: 2 })).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full match if groupName does not exist', () => {
|
||||||
|
expect(regexpExtract(/(?<foo>\w+)/, 'bar', { groupName: 'baz' })).toBe('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full match if groupNumber does not exist', () => {
|
||||||
|
expect(regexpExtract(/(\d+)/, '123', { groupNumber: 2 })).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with string pattern', () => {
|
||||||
|
expect(regexpExtract('(foo)', 'foo bar', { groupNumber: 1 })).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers groupName over groupNumber if both are provided', () => {
|
||||||
|
const pattern = /(?<word>\w+)\s(\w+)/;
|
||||||
|
expect(
|
||||||
|
regexpExtract(pattern, 'hello world', { groupName: 'word', groupNumber: 2 }),
|
||||||
|
).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null if match exists but group is undefined', () => {
|
||||||
|
expect(regexpExtract(/foo(bar)?/, 'foo', { groupNumber: 1 })).toBe('foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
|||||||
'utils/media-layout.ts',
|
'utils/media-layout.ts',
|
||||||
'utils/media.ts',
|
'utils/media.ts',
|
||||||
'utils/ptz.ts',
|
'utils/ptz.ts',
|
||||||
|
'utils/regexp-extract.ts',
|
||||||
'utils/screenshot.ts',
|
'utils/screenshot.ts',
|
||||||
'utils/scroll.ts',
|
'utils/scroll.ts',
|
||||||
'utils/substream.ts',
|
'utils/substream.ts',
|
||||||
|
|||||||
@@ -2500,6 +2500,7 @@ __metadata:
|
|||||||
"@typescript-eslint/eslint-plugin": "npm:^8.30.1"
|
"@typescript-eslint/eslint-plugin": "npm:^8.30.1"
|
||||||
"@typescript-eslint/parser": "npm:^8.30.1"
|
"@typescript-eslint/parser": "npm:^8.30.1"
|
||||||
"@vitest/coverage-istanbul": "npm:^1.6.0"
|
"@vitest/coverage-istanbul": "npm:^1.6.0"
|
||||||
|
any-date-parser: "npm:^2.2.0"
|
||||||
component-emitter: "npm:^1.3.1"
|
component-emitter: "npm:^1.3.1"
|
||||||
compute-scroll-into-view: "npm:^3.1.1"
|
compute-scroll-into-view: "npm:^3.1.1"
|
||||||
conventional-changelog-conventionalcommits: "npm:^8.0.0"
|
conventional-changelog-conventionalcommits: "npm:^8.0.0"
|
||||||
@@ -2697,6 +2698,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"any-date-parser@npm:^2.2.0":
|
||||||
|
version: 2.2.0
|
||||||
|
resolution: "any-date-parser@npm:2.2.0"
|
||||||
|
checksum: 10c0/e0d3cbadedc761c20fc351497a3fc8f467e2dbf9f3d90637c0ee966b7fc249beb2032d96cfaf9c91200a7c91cf2bff116e617a29afd0bcaf868bf35d595b4956
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"any-promise@npm:^1.0.0":
|
"any-promise@npm:^1.0.0":
|
||||||
version: 1.3.0
|
version: 1.3.0
|
||||||
resolution: "any-promise@npm:1.3.0"
|
resolution: "any-promise@npm:1.3.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user