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:
Dermot Duffy
2025-05-27 07:50:51 -07:00
committed by GitHub
parent c4400194f5
commit c077f8bf70
35 changed files with 1038 additions and 378 deletions
+198 -21
View File
@@ -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:
@@ -31,18 +31,28 @@ 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:
![](../images/folder-hierarchy-1.png 'Folder Hierarchy 1 :size=400')
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:
![](../images/folder-hierarchy-2.png 'Folder Hierarchy 2 :size=400')
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:
![](../images/folder-hierarchy-3.png 'Folder Hierarchy 3 :size=400')
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:
![](../images/folder-hierarchy-4.png 'Folder Hierarchy 2 :size=400')
### 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
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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));
} }
} }
}; };
+65 -54
View File
@@ -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,
});
}
}
+1
View File
@@ -0,0 +1 @@
export const REGEXP_GROUP_VALUE_KEY = 'value';
+3 -3
View File
@@ -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
? [ ? [
+2 -3
View File
@@ -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 }],
}), }),
}, },
}); });
+29 -7
View File
@@ -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[] => {
+1 -1
View File
@@ -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) {
+9 -2
View File
@@ -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');
};
+3 -3
View File
@@ -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:
+1 -1
View File
@@ -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;
} }
+22
View File
@@ -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;
};
+62 -120
View File
@@ -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,103 +220,57 @@ 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();
}); });
describe('should apply matchers', async () => {
it('should expand folder with title based query', async () => { it('should expand folder with title based query', async () => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [{ id: 'media-source://' }, { ha: { title: 'Frigate' } }],
};
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 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 = { const query: FolderQuery = {
folder: { type: 'ha' }, folder: { type: 'ha' },
path: [ path: [
{ id: 'media-source://' }, { ha: { id: 'media-source://' } },
{ ha: { title: 'Frigate' } }, { ha: { matchers: [{ type: 'title', title: 'Frigate' }] } },
{ id: 'media-source://frigate/subdir' }, {},
], ],
}; };
@@ -341,19 +294,7 @@ describe('HAFoldersEngine', () => {
can_expand: true, can_expand: true,
children: [ children: [
createBrowseMedia({ createBrowseMedia({
media_content_id: 'media-source://frigate/subdir', media_content_id: 'media-source://frigate/result',
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', title: 'Result',
}), }),
], ],
@@ -364,7 +305,8 @@ describe('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/subdir/result'); expect(results?.[0].getID()).toBe('media-source://frigate/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'),
}); });
+1 -1
View File
@@ -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);
});
});
+46
View File
@@ -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');
});
});
+1
View File
@@ -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',
+8
View File
@@ -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"