feat: Add or and template folder media matchers (#2071)

- For: #1748
This commit is contained in:
Dermot Duffy
2025-05-29 21:21:59 -07:00
committed by GitHub
parent c077f8bf70
commit dc63dcdab9
25 changed files with 502 additions and 145 deletions
+1
View File
@@ -19,6 +19,7 @@
- [`status_bar`](status-bar.md)
- [`timeline`](timeline.md)
- [`view`](view.md)
- [Templates](templates.md)
- [Examples](../examples.md)
- [Screenshots](../screenshots.md)
- [Troubleshooting](../troubleshooting.md)
+1 -1
View File
@@ -3,7 +3,6 @@
- [`actions`](README.md)
- [Custom Actions](./custom/README.md)
- [Stock Actions](./stock/README.md)
- [Templates](./templates.md)
- [`automations`](../automations.md)
- [`cameras`](../cameras/README.md)
- [`conditions`](../conditions.md)
@@ -22,6 +21,7 @@
- [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md)
- [`view`](../view.md)
- [Templates](../templates.md)
- [Examples](../../examples.md)
- [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md)
@@ -3,7 +3,6 @@
- [`actions`](../README.md)
- [Custom Actions](README.md)
- [Stock Actions](../stock/README.md)
- [Templates](../templates.md)
- [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
@@ -22,6 +21,7 @@
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
+1 -1
View File
@@ -3,7 +3,6 @@
- [`actions`](../README.md)
- [Custom Actions](../custom/README.md)
- [Stock Actions](README.md)
- [Templates](../templates.md)
- [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md)
@@ -22,6 +21,7 @@
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
-60
View File
@@ -1,60 +0,0 @@
# Templates
Before actions are executed, template values (if present) are replaced within
each action. This allows a variety of Home Assistant data to be automatically
populated in your actions, as well as some Advanced Camera Card data.
## Stock Templates
The Advanced Camera Card uses
[ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates.
Consult its documentation for the wide variety of different template values
supported.
See [an example](../../examples.md?id=accessing-home-assistant-state) that
accesses Home Assistant state.
## Custom Templates
Custom template values must be proceeded by `advanced_camera_card` (or `acc` for
short).
| Template | Replaced with |
| -------- | -------------------------------------------------- |
| `camera` | The currently selected camera. |
| `view` | The current [view](../view.md?id=supported-views). |
See [an example](../../examples.md?id=accessing-advanced-camera-card-state) that
accesses Advanced Camera Card state.
## Triggers
If the action is called by an [Advanced Camera Card
Automation](../automations.md), additional data is available representing the
current and prior state of whatever triggered the action.
Trigger template values must be proceeded by `advanced_camera_card.trigger` (or
`acc.trigger` for short).
| Template | Replaced with |
| -------------- | ------------------------------------------------------------------------------------------------- |
| `camera.to` | For [camera conditions](../conditions.md?id=camera), the currently selected camera. |
| `camera.from` | For [camera conditions](../conditions.md?id=camera), the previously selected camera. |
| `view.to` | For [view conditions](../conditions.md?id=view), the currently selected view. |
| `view.from` | For [view conditions](../conditions.md?id=view), the previously selected view. |
| `state.entity` | For [state conditions](../conditions.md?id=state), the entity state that triggered the condition. |
| `state.to` | For [state conditions](../conditions.md?id=state), the current state of the entity. |
| `state.from` | For [state conditions](../conditions.md?id=state), the previous state of the entity. |
!> If an action is triggered with multiple [state
conditions](../conditions.md?id=state), only data from the last listed state
condition is available.
!> If you use an [`or`](../conditions.md?id=or) condition, only the trigger data
for the first matching trigger will be included.
Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if
you need data from additional conditions.
See [an example](../../examples.md?id=accessing-trigger-state) that accesses
trigger state.
+1
View File
@@ -21,6 +21,7 @@
- [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md)
- [`view`](../view.md)
- [Templates](../templates.md)
- [Examples](../../examples.md)
- [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md)
+1
View File
@@ -21,6 +21,7 @@
- [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md)
- [`view`](../view.md)
- [Templates](../templates.md)
- [Examples](../../examples.md)
- [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md)
@@ -21,6 +21,7 @@
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
@@ -21,6 +21,7 @@
- [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md)
- [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md)
+40 -5
View File
@@ -72,6 +72,34 @@ 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.
##### Matcher: `or`
Match if any single matcher matches.
```yaml
type: or
# [...]
```
| Parameter | Description |
| --------- | ------------------------------------------------------------ |
| `type` | Must be `or`. |
| `matches` | An array of other matchers only one of which needs to match. |
##### Matcher: `template`
Match against a template.
```yaml
type: template
# [...]
```
| Parameter | Description |
| ---------------- | ---------------------------------------------------------------------- |
| `type` | Must be `template`. |
| `value_template` | A [template](./templates.md?id=media-matching) to match media against. |
##### Matcher: `title`
Match against the media item title.
@@ -81,11 +109,11 @@ 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. |
| Parameter | Description |
| --------- | -------------------------------------------------------------------- |
| `type` | Must be `title`. |
| `regexp` | An optional regular expression to match against the title. |
| `title` | An optional exact value (case-sensitive) to match against the title. |
#### Parsers
@@ -265,4 +293,11 @@ folders:
- type: startdate
format: HH:mm:ss
regexp: 'File (?<value>.*)'
- type: ha
ha:
path:
- id: 'media-source://'
- matchers:
- type: template
value_template: "{{ acc.media.title == now().strftime('%Y/%-m/%d') }}"
```
+76
View File
@@ -0,0 +1,76 @@
# Templates
Templates may be used in a certain places to allow template values (if present)
to be dynamically replaced. This allows a variety of Home Assistant data, and
Advanced Camera Card data, to be accessible. Templates may be used in:
- [Actions / Automations](./actions/README.md)
- [Folder Media Matchers](./folders.md?id=matchers)
## Stock Templates
The Advanced Camera Card uses
[ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates.
Consult its documentation for the wide variety of different template values
supported.
See [an example](../examples.md?id=accessing-home-assistant-state) that
accesses Home Assistant state.
## Custom Templates
Custom template values must be proceeded by `advanced_camera_card` (or `acc` for
short).
| Template | Replaced with |
| -------- | ------------------------------------------------- |
| `camera` | The currently selected camera. |
| `view` | The current [view](./view.md?id=supported-views). |
See [an example](../examples.md?id=accessing-advanced-camera-card-state) that
accesses Advanced Camera Card state.
### Media Matching
If templates are used for [Folder Media Matching](./folders.md?id=matchers) an
additional `media` variable is available with these properties:
Media template values must be proceeded by `advanced_camera_card.media` (or
`acc.media` for short).
| Template | Replaced with |
| ----------- | --------------------------------------------------------------------------------- |
| `title` | The media title being matched. |
| `is_folder` | Whether the media item is a folder that may be expanded (vs a single media item). |
### Triggers
If the action is called by an [Advanced Camera Card
Automation](./automations.md), additional data is available representing the
current and prior state of whatever triggered the action.
Trigger template values must be proceeded by `advanced_camera_card.trigger` (or
`acc.trigger` for short).
| Template | Replaced with |
| -------------- | ------------------------------------------------------------------------------------------------ |
| `camera.to` | For [camera conditions](./conditions.md?id=camera), the currently selected camera. |
| `camera.from` | For [camera conditions](./conditions.md?id=camera), the previously selected camera. |
| `view.to` | For [view conditions](./conditions.md?id=view), the currently selected view. |
| `view.from` | For [view conditions](./conditions.md?id=view), the previously selected view. |
| `state.entity` | For [state conditions](./conditions.md?id=state), the entity state that triggered the condition. |
| `state.to` | For [state conditions](./conditions.md?id=state), the current state of the entity. |
| `state.from` | For [state conditions](./conditions.md?id=state), the previous state of the entity. |
!> If an action is triggered with multiple [state
conditions](./conditions.md?id=state), only data from the last listed state
condition is available.
!> If you use an [`or`](./conditions.md?id=or) condition, only the trigger data
for the first matching trigger will be included.
Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if
you need data from additional conditions.
See [an example](../examples.md?id=accessing-trigger-state) that accesses
trigger state.
+37 -4
View File
@@ -385,7 +385,7 @@ 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
https://my-ha-instance.local/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:
@@ -426,6 +426,39 @@ folders:
regexp: 'Person.*'
```
### Folder `or` matching
This example dynamically includes media from two subfolders, one for today and
one for yesterday both in `%Y/%-m/%d`
[format](https://www.man7.org/linux/man-pages/man3/strftime.3.html).
[Templating](https://www.home-assistant.io/docs/configuration/templating/#time)
is used to dynamically refer to "today" and "yesterday".
```yaml
type: custom:advanced-camera-card
cameras:
- camera_entity: camera.office
folders:
- type: ha
ha:
url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0
path:
- matchers:
- type: title
title: 'Low resolution'
- parsers:
- type: startdate
matchers:
- type: or
matchers:
- type: template
value_template: "{{ acc.media.title == now().strftime('%Y/%-m/%d') }}"
- type: template
value_template: "{{ acc.media.title == (now() - dt.timedelta(days=1)) | timestamp_custom('%Y/%-m/%d') }}"
- parsers:
- type: startdate
```
### Folder Paths
This example starts with the `media-source://frigate` folder, and looks for a
@@ -1072,7 +1105,7 @@ tap_action:
key: '{{ hass.states["light.sunroom_ceiling"].state }}'
```
See [Stock Templates](./configuration/actions/templates.md?id=stock-templates).
See [Stock Templates](./configuration/templates.md?id=stock-templates).
### Accessing Advanced Camera Card state
@@ -1089,7 +1122,7 @@ tap_action:
view: '{{ advanced_camera_card.view }}'
```
See [Custom Templates](./configuration/actions/templates.md?id=custom-templates).
See [Custom Templates](./configuration/templates.md?id=custom-templates).
### Accessing Trigger state
@@ -1108,7 +1141,7 @@ automations:
to_camera: '{{ acc.trigger.camera.to }}'
```
See [Trigger Templates](./configuration/actions/templates.md?id=triggers).
See [Trigger Templates](./configuration/templates.md?id=triggers).
## Trigger actions
+3
View File
@@ -1,3 +1,4 @@
import { ConditionState } from '../../conditions/types';
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
@@ -23,12 +24,14 @@ export class FoldersExecutor {
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const results =
(await this._getFolderEngine(query.folder.type)?.expandFolder(
hass,
query,
conditionState,
engineOptions,
)) ?? null;
return results ? sortItems(results) : null;
+7 -5
View File
@@ -1,4 +1,5 @@
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../../conditions/types';
import {
FolderConfig,
folderTypeSchema,
@@ -112,6 +113,7 @@ export class HAFoldersEngine implements FoldersEngine {
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
if (query.folder.type !== folderTypeSchema.enum.ha) {
@@ -163,13 +165,13 @@ export class HAFoldersEngine implements FoldersEngine {
...(nextComponent && {
matcher: (media: BrowseMedia) =>
this._mediaMatcher.match(
media,
nextComponent.ha?.matchers,
this._mediaMatcher.match(hass, media, {
matchers: 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,
),
foldersOnly: pathComponents.length > 0,
conditionState,
}),
advance: (targets) => (pathComponents.length ? generateStep(targets) : []),
}),
},
+61 -11
View File
@@ -1,29 +1,79 @@
import { Matcher } from '../../../config/schema/folders';
import { ConditionState } from '../../../conditions/types';
import { Matcher, TemplateMatcher, TitleMatcher } from '../../../config/schema/folders';
import { BrowseMedia } from '../../../ha/browse-media/types';
import { HomeAssistant } from '../../../ha/types';
import { regexpExtract } from '../../../utils/regexp-extract';
import { TemplateRenderer } from '../../templates';
import { REGEXP_GROUP_VALUE_KEY } from './types';
export class MediaMatcher {
public match(media: BrowseMedia, matchers?: Matcher[], foldersOnly = false): boolean {
if (foldersOnly && !media.can_expand) {
private _templateRenderer = new TemplateRenderer();
public match(
hass: HomeAssistant,
media: BrowseMedia,
options?: {
foldersOnly?: boolean;
matchers?: Matcher[];
conditionState?: ConditionState;
},
): boolean {
if (options?.foldersOnly && !media.can_expand) {
return false;
}
for (const matcher of matchers ?? []) {
if (matcher.type === 'title') {
if (!this._matchTitle(matcher, media.title)) {
return false;
}
for (const matcher of options?.matchers ?? []) {
switch (matcher.type) {
case 'template':
if (!this._matchTemplate(hass, matcher, media, options?.conditionState)) {
return false;
}
break;
case 'title':
if (!this._matchTitle(matcher, media)) {
return false;
}
break;
case 'or':
if (
!matcher.matchers.some((subMatcher) =>
this.match(hass, media, {
foldersOnly: options?.foldersOnly,
matchers: [subMatcher],
conditionState: options?.conditionState,
}),
)
) {
return false;
}
break;
}
}
return true;
}
private _matchTitle(matcher: Matcher, src: string): boolean {
private _matchTemplate(
hass: HomeAssistant,
matcher: TemplateMatcher,
media: BrowseMedia,
conditionState?: ConditionState,
): boolean {
return (
this._templateRenderer.renderRecursively(hass, matcher.value_template, {
conditionState,
mediaData: {
title: media.title,
is_folder: media.can_expand,
},
}) === true
);
}
private _matchTitle(matcher: TitleMatcher, media: BrowseMedia): boolean {
const valueToMatch = matcher.regexp
? regexpExtract(matcher.regexp, src, { groupName: REGEXP_GROUP_VALUE_KEY })
: src;
? regexpExtract(matcher.regexp, media.title, { groupName: REGEXP_GROUP_VALUE_KEY })
: media.title;
if (!valueToMatch) {
return false;
+5 -1
View File
@@ -1,4 +1,5 @@
import { cloneDeep } from 'lodash-es';
import { ConditionState } from '../../conditions/types';
import { FolderConfig } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { Endpoint } from '../../types';
@@ -63,10 +64,13 @@ export class FoldersManager {
public async expandFolder(
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const hass = this._api.getHASSManager().getHASS();
return hass ? this._executor.expandFolder(hass, query, engineOptions) : null;
return hass
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
: null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
+2
View File
@@ -1,4 +1,5 @@
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../conditions/types';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
@@ -47,6 +48,7 @@ export interface FoldersEngine {
expandFolder(
hass: HomeAssistant,
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null>;
+22 -12
View File
@@ -2,10 +2,15 @@ import { HASS, renderTemplate } from 'ha-nunjucks/dist';
import { ConditionState, ConditionsTriggerData } from '../../conditions/types';
import { HomeAssistant } from '../../ha/types';
interface TemplateMediaData {
title: string;
is_folder: boolean;
}
interface TemplateContextInternal {
camera?: string;
view?: string;
trigger?: ConditionsTriggerData;
media?: TemplateMediaData;
}
interface TemplateContext {
@@ -22,30 +27,35 @@ export class TemplateRenderer {
options?: {
conditionState?: ConditionState;
triggerData?: ConditionsTriggerData;
mediaData?: TemplateMediaData;
},
): unknown => {
return this._renderTemplateRecursively(
hass,
data,
this._conditionStateToTemplateContext(
options?.conditionState,
options?.triggerData,
),
this._generateTemplateContext(options),
);
};
protected _conditionStateToTemplateContext(
conditionState?: ConditionState,
triggerData?: ConditionsTriggerData,
): TemplateContext | undefined {
if (!conditionState?.camera && !conditionState?.view && !triggerData) {
protected _generateTemplateContext(options?: {
conditionState?: ConditionState;
triggerData?: ConditionsTriggerData;
mediaData?: TemplateMediaData;
}): TemplateContext | undefined {
if (
!options?.conditionState?.camera &&
!options?.conditionState?.view &&
!options?.triggerData &&
!options?.mediaData
) {
return;
}
const advancedCameraCardContext: TemplateContextInternal = {
...(conditionState?.camera && { camera: conditionState.camera }),
...(conditionState?.view && { view: conditionState.view }),
...(triggerData && { trigger: triggerData }),
...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
...(options?.conditionState?.view && { view: options.conditionState.view }),
...(options?.triggerData && { trigger: options.triggerData }),
...(options?.mediaData && { media: options.mediaData }),
};
return {
+3 -1
View File
@@ -159,7 +159,9 @@ export class QueryExecutor {
}
const itemArray = await this._api
.getFoldersManager()
.expandFolder(rawQuery, { useCache: executorOptions?.useCache });
.expandFolder(rawQuery, this._api.getConditionStateManager().getState(), {
useCache: executorOptions?.useCache,
});
const queryResults = itemArray
? this._generateQueriesResults(itemArray, executorOptions)
+22 -2
View File
@@ -1,8 +1,8 @@
import { NonEmptyTuple } from 'type-fest';
import { z } from 'zod';
import { AdvancedCameraCardError } from '../../types';
import { isTruthy } from '../../utils/basic';
import { regexSchema } from './common/regex';
import { AdvancedCameraCardError } from '../../types';
export const HA_MEDIA_SOURCE_ROOT = 'media-source://';
@@ -31,12 +31,32 @@ const parserSchema = z.discriminatedUnion('type', [
]);
export type Parser = z.infer<typeof parserSchema>;
const templateMatcherSchema = parserBaseSchema.extend({
type: z.literal('template'),
value_template: z.string(),
});
export type TemplateMatcher = z.infer<typeof templateMatcherSchema>;
const titleMatcherSchema = parserBaseSchema.extend({
type: z.literal('title'),
regexp: regexSchema.optional(),
title: z.string().optional(),
});
const matcherSchema = z.discriminatedUnion('type', [titleMatcherSchema]);
export type TitleMatcher = z.infer<typeof titleMatcherSchema>;
type OrMatcher = {
type: 'or';
matchers: Matcher[];
};
const orMatcherSchema: z.ZodSchema<OrMatcher, z.ZodTypeDef> = z.object({
type: z.literal('or'),
matchers: z.array(z.lazy(() => matcherSchema)),
});
export const matcherSchema = z.union([
orMatcherSchema,
templateMatcherSchema,
titleMatcherSchema,
]);
export type Matcher = z.infer<typeof matcherSchema>;
const haFolderPathComponentSchema = z.object({
@@ -110,7 +110,7 @@ describe('FoldersExecutor', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
path: [{ ha: { id: 'media-source://' } }],
};
const haFolderEngine = mock<HAFoldersEngine>();
@@ -145,7 +145,7 @@ describe('FoldersExecutor', () => {
const folder = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
path: [{ ha: { id: 'media-source://' } }],
};
const mediaItem = new TestViewMedia({
+35 -14
View File
@@ -1,8 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine';
import { FolderQuery } from '../../../../src/card-controller/folders/types';
import { FolderConfig } from '../../../../src/config/schema/folders';
import { FolderConfig, Matcher } 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';
@@ -206,9 +205,14 @@ describe('HAFoldersEngine', () => {
.mockResolvedValueOnce([]);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query, {
useCache: false,
});
const results = await engine.expandFolder(
createHASS(),
query,
{},
{
useCache: false,
},
);
expect(results?.length).toBe(2);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[1]).toBeInstanceOf(ViewFolder);
@@ -263,15 +267,34 @@ describe('HAFoldersEngine', () => {
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
});
// See additional matcher testing in media-matcher.test.ts .
describe('should apply matchers', async () => {
it('should expand folder with title based query', async () => {
it.each([
['title exact', { type: 'title' as const, title: 'Frigate' }, 1],
['title regexp', { type: 'title' as const, regexp: 'rig' }, 1],
[
'or positive',
{
type: 'or' as const,
matchers: [
{ type: 'title' as const, title: 'UNKNOWN' },
{ type: 'title' as const, title: 'Frigate' },
],
},
1,
],
[
'or negative',
{
type: 'or' as const,
matchers: [{ type: 'title' as const, title: 'UNKNOWN' }],
},
0,
],
])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [
{ ha: { id: 'media-source://' } },
{ ha: { matchers: [{ type: 'title', title: 'Frigate' }] } },
{},
],
path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}],
};
vi.mocked(homeAssistantWSRequest)
@@ -303,9 +326,7 @@ describe('HAFoldersEngine', () => {
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');
expect(results?.length).toBe(expectedMatches);
});
});
});
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';
import { renderTemplate } from 'ha-nunjucks';
import { describe, expect, it, vi } 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';
import { createHASS } from '../../../test-utils';
vi.mock('ha-nunjucks');
describe('MediaMatcher', () => {
describe('match', () => {
@@ -22,25 +26,31 @@ describe('MediaMatcher', () => {
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);
expect(
mediaMatcher.match(createHASS(), media, { matchers: [], foldersOnly: 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);
expect(
mediaMatcher.match(createHASS(), media, { matchers: [], foldersOnly: 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);
expect(mediaMatcher.match(createHASS(), media, { matchers: [] })).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);
expect(mediaMatcher.match(createHASS(), media, { matchers: undefined })).toBe(
true,
);
});
describe('with title matcher', () => {
@@ -48,14 +58,14 @@ describe('MediaMatcher', () => {
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);
expect(mediaMatcher.match(createHASS(), 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);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return true when title matches regexp and extracted value matches matcher.title', () => {
@@ -68,7 +78,7 @@ describe('MediaMatcher', () => {
title: 'ImportantPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
it('should return false when title matches regexp but extracted value does not match matcher.title', () => {
@@ -81,7 +91,7 @@ describe('MediaMatcher', () => {
title: 'WrongPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return true when title matches regexp with an explicit title value', () => {
@@ -94,7 +104,7 @@ describe('MediaMatcher', () => {
// title is undefined.
},
];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
it('should return false when title does not match regexp', () => {
@@ -107,7 +117,7 @@ describe('MediaMatcher', () => {
title: 'ImportantPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return false when regexp is provided but does not extract the required group', () => {
@@ -120,14 +130,120 @@ describe('MediaMatcher', () => {
title: 'ImportantPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), 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);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
});
describe('with template matcher', () => {
it('should return true when template value matches', () => {
const mediaMatcher = new MediaMatcher();
const title = 'Any Title Will Do';
const media = createMediaItem(title, false);
vi.mocked(renderTemplate).mockReturnValue(true);
const matchers: Matcher[] = [
{
type: 'template',
value_template: '{{ acc.media.title == "Any Title Will Do" }}',
},
];
const hass = createHASS();
expect(mediaMatcher.match(hass, media, { matchers })).toBe(true);
expect(renderTemplate).toHaveBeenCalledWith(
hass,
'{{ acc.media.title == "Any Title Will Do" }}',
{
acc: {
media: {
title,
is_folder: false,
},
},
advanced_camera_card: {
media: {
title,
is_folder: false,
},
},
},
);
});
it('should return false when template value does not match', () => {
const mediaMatcher = new MediaMatcher();
const title = 'Any Title Will Do';
const media = createMediaItem(title, false);
vi.mocked(renderTemplate).mockReturnValue(false);
const matchers: Matcher[] = [
{
type: 'template',
value_template: '{{ acc.media.title == "Any Title Will Do" }}',
},
];
const hass = createHASS();
expect(mediaMatcher.match(hass, media, { matchers })).toBe(false);
expect(renderTemplate).toHaveBeenCalledWith(
hass,
'{{ acc.media.title == "Any Title Will Do" }}',
{
acc: {
media: {
title,
is_folder: false,
},
},
advanced_camera_card: {
media: {
title,
is_folder: false,
},
},
},
);
});
});
describe('with or matcher', () => {
it('should return true if at least one sub-matcher matches', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false);
const matcher: Matcher = {
type: 'or',
matchers: [
{ type: 'title', title: 'Non-Matching Title' }, // Fails
{ type: 'title', title: 'Test Media' }, // Passes
],
};
expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe(
true,
);
});
it('should return false if no sub-matcher matches', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false);
const matcher: Matcher = {
type: 'or',
matchers: [
{ type: 'title', title: 'Non-Matching Title One' }, // Fails
{ type: 'title', title: 'Non-Matching Title Two' }, // Fails
],
};
expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe(
false,
);
});
});
@@ -138,7 +254,7 @@ describe('MediaMatcher', () => {
{ type: 'title', title: 'Test Media One' }, // Pass
{ type: 'title', title: 'Test Media Two' }, // Fail
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return true if all multiple matchers pass', () => {
@@ -152,7 +268,7 @@ describe('MediaMatcher', () => {
title: 'Test Media One',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
it('should ignore matchers of unknown types', () => {
@@ -160,7 +276,7 @@ describe('MediaMatcher', () => {
const media = createMediaItem('Test Media', false);
const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
});
});
+18 -7
View File
@@ -130,7 +130,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
@@ -146,7 +146,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
@@ -162,7 +162,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
@@ -187,15 +187,23 @@ describe('FoldersManager', () => {
const manager = new FoldersManager(api, executor);
const folder = createFolder({ id: 'folder-1' });
const conditionState = {};
const engineOptions = {};
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
expect(await manager.expandFolder(query, engineOptions)).toEqual([media]);
expect(await manager.expandFolder(query, conditionState, engineOptions)).toEqual([
media,
]);
expect(executor.expandFolder).toBeCalledWith(hass, query, engineOptions);
expect(executor.expandFolder).toBeCalledWith(
hass,
query,
conditionState,
engineOptions,
);
});
it('should not expand folder with hass', async () => {
@@ -205,7 +213,10 @@ describe('FoldersManager', () => {
const folder = createFolder({ id: 'folder-1' });
expect(
await manager.expandFolder({ folder, path: [{ id: 'media-source://' }] }),
await manager.expandFolder({
folder,
path: [{ ha: { id: 'media-source://' } }],
}),
).toBeNull();
expect(executor.expandFolder).not.toBeCalled();
+29 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { transformPathURLToPathArray } from '../../../src/config/schema/folders';
import { NonEmptyTuple } from 'type-fest';
import { describe, expect, it } from 'vitest';
import {
matcherSchema,
transformPathURLToPathArray,
} from '../../../src/config/schema/folders';
describe('transformURLToMediaSourceRoot', () => {
const prefixes: NonEmptyTuple<string>[] = [
@@ -65,3 +68,27 @@ describe('transformURLToMediaSourceRoot', () => {
});
});
});
describe('should lazy evaluate schemas', () => {
it('should lazy evaluate or matcher', () => {
expect(
matcherSchema.parse({
type: 'or',
matchers: [
{
type: 'title',
title: 'Test Title',
},
],
}),
).toEqual({
type: 'or',
matchers: [
{
type: 'title',
title: 'Test Title',
},
],
});
});
});