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) - [`status_bar`](status-bar.md)
- [`timeline`](timeline.md) - [`timeline`](timeline.md)
- [`view`](view.md) - [`view`](view.md)
- [Templates](templates.md)
- [Examples](../examples.md) - [Examples](../examples.md)
- [Screenshots](../screenshots.md) - [Screenshots](../screenshots.md)
- [Troubleshooting](../troubleshooting.md) - [Troubleshooting](../troubleshooting.md)
+1 -1
View File
@@ -3,7 +3,6 @@
- [`actions`](README.md) - [`actions`](README.md)
- [Custom Actions](./custom/README.md) - [Custom Actions](./custom/README.md)
- [Stock Actions](./stock/README.md) - [Stock Actions](./stock/README.md)
- [Templates](./templates.md)
- [`automations`](../automations.md) - [`automations`](../automations.md)
- [`cameras`](../cameras/README.md) - [`cameras`](../cameras/README.md)
- [`conditions`](../conditions.md) - [`conditions`](../conditions.md)
@@ -22,6 +21,7 @@
- [`status_bar`](../status-bar.md) - [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md) - [`timeline`](../timeline.md)
- [`view`](../view.md) - [`view`](../view.md)
- [Templates](../templates.md)
- [Examples](../../examples.md) - [Examples](../../examples.md)
- [Screenshots](../../screenshots.md) - [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md) - [Troubleshooting](../../troubleshooting.md)
@@ -3,7 +3,6 @@
- [`actions`](../README.md) - [`actions`](../README.md)
- [Custom Actions](README.md) - [Custom Actions](README.md)
- [Stock Actions](../stock/README.md) - [Stock Actions](../stock/README.md)
- [Templates](../templates.md)
- [`automations`](../../automations.md) - [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md) - [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md) - [`conditions`](../../conditions.md)
@@ -22,6 +21,7 @@
- [`status_bar`](../../status-bar.md) - [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md) - [`timeline`](../../timeline.md)
- [`view`](../../view.md) - [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md) - [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md) - [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md) - [Troubleshooting](../../../troubleshooting.md)
+1 -1
View File
@@ -3,7 +3,6 @@
- [`actions`](../README.md) - [`actions`](../README.md)
- [Custom Actions](../custom/README.md) - [Custom Actions](../custom/README.md)
- [Stock Actions](README.md) - [Stock Actions](README.md)
- [Templates](../templates.md)
- [`automations`](../../automations.md) - [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md) - [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md) - [`conditions`](../../conditions.md)
@@ -22,6 +21,7 @@
- [`status_bar`](../../status-bar.md) - [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md) - [`timeline`](../../timeline.md)
- [`view`](../../view.md) - [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md) - [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md) - [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.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) - [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md) - [`timeline`](../timeline.md)
- [`view`](../view.md) - [`view`](../view.md)
- [Templates](../templates.md)
- [Examples](../../examples.md) - [Examples](../../examples.md)
- [Screenshots](../../screenshots.md) - [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md) - [Troubleshooting](../../troubleshooting.md)
+1
View File
@@ -21,6 +21,7 @@
- [`status_bar`](../status-bar.md) - [`status_bar`](../status-bar.md)
- [`timeline`](../timeline.md) - [`timeline`](../timeline.md)
- [`view`](../view.md) - [`view`](../view.md)
- [Templates](../templates.md)
- [Examples](../../examples.md) - [Examples](../../examples.md)
- [Screenshots](../../screenshots.md) - [Screenshots](../../screenshots.md)
- [Troubleshooting](../../troubleshooting.md) - [Troubleshooting](../../troubleshooting.md)
@@ -21,6 +21,7 @@
- [`status_bar`](../../status-bar.md) - [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md) - [`timeline`](../../timeline.md)
- [`view`](../../view.md) - [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md) - [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md) - [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md) - [Troubleshooting](../../../troubleshooting.md)
@@ -21,6 +21,7 @@
- [`status_bar`](../../status-bar.md) - [`status_bar`](../../status-bar.md)
- [`timeline`](../../timeline.md) - [`timeline`](../../timeline.md)
- [`view`](../../view.md) - [`view`](../../view.md)
- [Templates](../../templates.md)
- [Examples](../../../examples.md) - [Examples](../../../examples.md)
- [Screenshots](../../../screenshots.md) - [Screenshots](../../../screenshots.md)
- [Troubleshooting](../../../troubleshooting.md) - [Troubleshooting](../../../troubleshooting.md)
+38 -3
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) does not match, it will not be returned to the user nor (in case of subfolders)
feature in future traversals. 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` ##### Matcher: `title`
Match against the media item title. Match against the media item title.
@@ -82,10 +110,10 @@ type: title
``` ```
| Parameter | Description | | Parameter | Description |
| --------- | ---------------------------------------------------------------------- | | --------- | -------------------------------------------------------------------- |
| `type` | Must be `title`. | | `type` | Must be `title`. |
| `regexp` | An optional regular expression to matches against the title. | | `regexp` | An optional regular expression to match against the title. |
| `title` | An optional exact value (case-sensitive) to matches against the title. | | `title` | An optional exact value (case-sensitive) to match against the title. |
#### Parsers #### Parsers
@@ -265,4 +293,11 @@ folders:
- type: startdate - type: startdate
format: HH:mm:ss format: HH:mm:ss
regexp: 'File (?<value>.*)' 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 - type: ha
ha: ha:
url: >- 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: path:
# Matches against the "Low resolution" folder. # Matches against the "Low resolution" folder.
- matchers: - matchers:
@@ -426,6 +426,39 @@ folders:
regexp: 'Person.*' 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 ### Folder Paths
This example starts with the `media-source://frigate` folder, and looks for a 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 }}' 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 ### Accessing Advanced Camera Card state
@@ -1089,7 +1122,7 @@ tap_action:
view: '{{ advanced_camera_card.view }}' 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 ### Accessing Trigger state
@@ -1108,7 +1141,7 @@ automations:
to_camera: '{{ acc.trigger.camera.to }}' 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 ## Trigger actions
+3
View File
@@ -1,3 +1,4 @@
import { ConditionState } from '../../conditions/types';
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders'; import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types'; import { Endpoint } from '../../types';
@@ -23,12 +24,14 @@ export class FoldersExecutor {
public async expandFolder( public async expandFolder(
hass: HomeAssistant, hass: HomeAssistant,
query: FolderQuery, query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> { ): Promise<ViewItem[] | null> {
const results = const results =
(await this._getFolderEngine(query.folder.type)?.expandFolder( (await this._getFolderEngine(query.folder.type)?.expandFolder(
hass, hass,
query, query,
conditionState,
engineOptions, engineOptions,
)) ?? null; )) ?? null;
return results ? sortItems(results) : null; return results ? sortItems(results) : null;
+7 -5
View File
@@ -1,4 +1,5 @@
import { NonEmptyTuple } from 'type-fest'; import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../../conditions/types';
import { import {
FolderConfig, FolderConfig,
folderTypeSchema, folderTypeSchema,
@@ -112,6 +113,7 @@ export class HAFoldersEngine implements FoldersEngine {
public async expandFolder( public async expandFolder(
hass: HomeAssistant, hass: HomeAssistant,
query: FolderQuery, query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> { ): Promise<ViewItem[] | null> {
if (query.folder.type !== folderTypeSchema.enum.ha) { if (query.folder.type !== folderTypeSchema.enum.ha) {
@@ -163,13 +165,13 @@ export class HAFoldersEngine implements FoldersEngine {
...(nextComponent && { ...(nextComponent && {
matcher: (media: BrowseMedia) => matcher: (media: BrowseMedia) =>
this._mediaMatcher.match( this._mediaMatcher.match(hass, media, {
media, matchers: nextComponent.ha?.matchers,
nextComponent.ha?.matchers,
// Set foldersOnly to true if there are more stages in the path, // Set foldersOnly to true if there are more stages in the path,
// as by definition only folders can be matched at this point. // 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) : []), advance: (targets) => (pathComponents.length ? generateStep(targets) : []),
}), }),
}, },
@@ -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 { BrowseMedia } from '../../../ha/browse-media/types';
import { HomeAssistant } from '../../../ha/types';
import { regexpExtract } from '../../../utils/regexp-extract'; import { regexpExtract } from '../../../utils/regexp-extract';
import { TemplateRenderer } from '../../templates';
import { REGEXP_GROUP_VALUE_KEY } from './types'; import { REGEXP_GROUP_VALUE_KEY } from './types';
export class MediaMatcher { export class MediaMatcher {
public match(media: BrowseMedia, matchers?: Matcher[], foldersOnly = false): boolean { private _templateRenderer = new TemplateRenderer();
if (foldersOnly && !media.can_expand) {
public match(
hass: HomeAssistant,
media: BrowseMedia,
options?: {
foldersOnly?: boolean;
matchers?: Matcher[];
conditionState?: ConditionState;
},
): boolean {
if (options?.foldersOnly && !media.can_expand) {
return false; return false;
} }
for (const matcher of matchers ?? []) { for (const matcher of options?.matchers ?? []) {
if (matcher.type === 'title') { switch (matcher.type) {
if (!this._matchTitle(matcher, media.title)) { case 'template':
if (!this._matchTemplate(hass, matcher, media, options?.conditionState)) {
return false; 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; 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 const valueToMatch = matcher.regexp
? regexpExtract(matcher.regexp, src, { groupName: REGEXP_GROUP_VALUE_KEY }) ? regexpExtract(matcher.regexp, media.title, { groupName: REGEXP_GROUP_VALUE_KEY })
: src; : media.title;
if (!valueToMatch) { if (!valueToMatch) {
return false; return false;
+5 -1
View File
@@ -1,4 +1,5 @@
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import { ConditionState } from '../../conditions/types';
import { FolderConfig } from '../../config/schema/folders'; import { FolderConfig } from '../../config/schema/folders';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { Endpoint } from '../../types'; import { Endpoint } from '../../types';
@@ -63,10 +64,13 @@ export class FoldersManager {
public async expandFolder( public async expandFolder(
query: FolderQuery, query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> { ): Promise<ViewItem[] | null> {
const hass = this._api.getHASSManager().getHASS(); 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 { public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
+2
View File
@@ -1,4 +1,5 @@
import { NonEmptyTuple } from 'type-fest'; import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../conditions/types';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders'; import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media'; import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
@@ -47,6 +48,7 @@ export interface FoldersEngine {
expandFolder( expandFolder(
hass: HomeAssistant, hass: HomeAssistant,
query: FolderQuery, query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<ViewItem[] | null>; ): 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 { ConditionState, ConditionsTriggerData } from '../../conditions/types';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
interface TemplateMediaData {
title: string;
is_folder: boolean;
}
interface TemplateContextInternal { interface TemplateContextInternal {
camera?: string; camera?: string;
view?: string; view?: string;
trigger?: ConditionsTriggerData; trigger?: ConditionsTriggerData;
media?: TemplateMediaData;
} }
interface TemplateContext { interface TemplateContext {
@@ -22,30 +27,35 @@ export class TemplateRenderer {
options?: { options?: {
conditionState?: ConditionState; conditionState?: ConditionState;
triggerData?: ConditionsTriggerData; triggerData?: ConditionsTriggerData;
mediaData?: TemplateMediaData;
}, },
): unknown => { ): unknown => {
return this._renderTemplateRecursively( return this._renderTemplateRecursively(
hass, hass,
data, data,
this._conditionStateToTemplateContext( this._generateTemplateContext(options),
options?.conditionState,
options?.triggerData,
),
); );
}; };
protected _conditionStateToTemplateContext( protected _generateTemplateContext(options?: {
conditionState?: ConditionState, conditionState?: ConditionState;
triggerData?: ConditionsTriggerData, triggerData?: ConditionsTriggerData;
): TemplateContext | undefined { mediaData?: TemplateMediaData;
if (!conditionState?.camera && !conditionState?.view && !triggerData) { }): TemplateContext | undefined {
if (
!options?.conditionState?.camera &&
!options?.conditionState?.view &&
!options?.triggerData &&
!options?.mediaData
) {
return; return;
} }
const advancedCameraCardContext: TemplateContextInternal = { const advancedCameraCardContext: TemplateContextInternal = {
...(conditionState?.camera && { camera: conditionState.camera }), ...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
...(conditionState?.view && { view: conditionState.view }), ...(options?.conditionState?.view && { view: options.conditionState.view }),
...(triggerData && { trigger: triggerData }), ...(options?.triggerData && { trigger: options.triggerData }),
...(options?.mediaData && { media: options.mediaData }),
}; };
return { return {
+3 -1
View File
@@ -159,7 +159,9 @@ export class QueryExecutor {
} }
const itemArray = await this._api const itemArray = await this._api
.getFoldersManager() .getFoldersManager()
.expandFolder(rawQuery, { useCache: executorOptions?.useCache }); .expandFolder(rawQuery, this._api.getConditionStateManager().getState(), {
useCache: executorOptions?.useCache,
});
const queryResults = itemArray const queryResults = itemArray
? this._generateQueriesResults(itemArray, executorOptions) ? this._generateQueriesResults(itemArray, executorOptions)
+22 -2
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://';
@@ -31,12 +31,32 @@ const parserSchema = z.discriminatedUnion('type', [
]); ]);
export type Parser = z.infer<typeof parserSchema>; 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({ const titleMatcherSchema = parserBaseSchema.extend({
type: z.literal('title'), type: z.literal('title'),
regexp: regexSchema.optional(), regexp: regexSchema.optional(),
title: z.string().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>; export type Matcher = z.infer<typeof matcherSchema>;
const haFolderPathComponentSchema = z.object({ const haFolderPathComponentSchema = z.object({
@@ -110,7 +110,7 @@ describe('FoldersExecutor', () => {
const folder: FolderConfig = createFolder(); const folder: FolderConfig = createFolder();
const query: FolderQuery = { const query: FolderQuery = {
folder, folder,
path: ['media-source://'], path: [{ ha: { id: 'media-source://' } }],
}; };
const haFolderEngine = mock<HAFoldersEngine>(); const haFolderEngine = mock<HAFoldersEngine>();
@@ -145,7 +145,7 @@ describe('FoldersExecutor', () => {
const folder = createFolder(); const folder = createFolder();
const query: FolderQuery = { const query: FolderQuery = {
folder, folder,
path: ['media-source://'], path: [{ ha: { id: 'media-source://' } }],
}; };
const mediaItem = new TestViewMedia({ const mediaItem = new TestViewMedia({
+34 -13
View File
@@ -1,8 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; 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, Matcher } from '../../../../src/config/schema/folders';
import { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item'; import { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item';
import { browseMediaSchema } from '../../../../src/ha/browse-media/types'; import { browseMediaSchema } from '../../../../src/ha/browse-media/types';
import { getMediaDownloadPath } from '../../../../src/ha/download'; import { getMediaDownloadPath } from '../../../../src/ha/download';
@@ -206,9 +205,14 @@ describe('HAFoldersEngine', () => {
.mockResolvedValueOnce([]); .mockResolvedValueOnce([]);
const engine = new HAFoldersEngine(); const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query, { const results = await engine.expandFolder(
createHASS(),
query,
{},
{
useCache: false, useCache: false,
}); },
);
expect(results?.length).toBe(2); expect(results?.length).toBe(2);
expect(results?.[0]).toBeInstanceOf(ViewMedia); expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[1]).toBeInstanceOf(ViewFolder); expect(results?.[1]).toBeInstanceOf(ViewFolder);
@@ -263,15 +267,34 @@ describe('HAFoldersEngine', () => {
expect(await engine.expandFolder(createHASS(), query)).toBeNull(); expect(await engine.expandFolder(createHASS(), query)).toBeNull();
}); });
// See additional matcher testing in media-matcher.test.ts .
describe('should apply matchers', async () => { 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 = { const query: FolderQuery = {
folder: { type: 'ha' }, folder: { type: 'ha' },
path: [ path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}],
{ ha: { id: 'media-source://' } },
{ ha: { matchers: [{ type: 'title', title: 'Frigate' }] } },
{},
],
}; };
vi.mocked(homeAssistantWSRequest) vi.mocked(homeAssistantWSRequest)
@@ -303,9 +326,7 @@ describe('HAFoldersEngine', () => {
const engine = new HAFoldersEngine(); const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query); const results = await engine.expandFolder(createHASS(), query);
expect(results?.length).toBe(1); expect(results?.length).toBe(expectedMatches);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[0].getID()).toBe('media-source://frigate/result');
}); });
}); });
}); });
@@ -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 { MediaMatcher } from '../../../../src/card-controller/folders/ha/media-matcher';
import { Matcher } from '../../../../src/config/schema/folders'; import { Matcher } from '../../../../src/config/schema/folders';
import { BrowseMedia } from '../../../../src/ha/browse-media/types'; import { BrowseMedia } from '../../../../src/ha/browse-media/types';
import { createHASS } from '../../../test-utils';
vi.mock('ha-nunjucks');
describe('MediaMatcher', () => { describe('MediaMatcher', () => {
describe('match', () => { describe('match', () => {
@@ -22,25 +26,31 @@ describe('MediaMatcher', () => {
it('should return false if foldersOnly is true and media.can_expand is false', () => { it('should return false if foldersOnly is true and media.can_expand is false', () => {
const mediaMatcher = new MediaMatcher(); const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test File', false); 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', () => { it('should return true if foldersOnly is true and media.can_expand is true', () => {
const mediaMatcher = new MediaMatcher(); const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Folder', true, 'directory'); 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', () => { it('should return true if matchers array is empty', () => {
const mediaMatcher = new MediaMatcher(); const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false); 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', () => { it('should return true if matchers array is undefined', () => {
const mediaMatcher = new MediaMatcher(); const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false); 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', () => { describe('with title matcher', () => {
@@ -48,14 +58,14 @@ describe('MediaMatcher', () => {
const mediaMatcher = new MediaMatcher(); const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Exact Title', false); const media = createMediaItem('Exact Title', false);
const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; 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', () => { it('should return false when title does not match exactly', () => {
const mediaMatcher = new MediaMatcher(); const mediaMatcher = new MediaMatcher();
const media = createMediaItem('DOES NOT MATCH', false); const media = createMediaItem('DOES NOT MATCH', false);
const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; 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', () => { it('should return true when title matches regexp and extracted value matches matcher.title', () => {
@@ -68,7 +78,7 @@ describe('MediaMatcher', () => {
title: 'ImportantPart', 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', () => { it('should return false when title matches regexp but extracted value does not match matcher.title', () => {
@@ -81,7 +91,7 @@ describe('MediaMatcher', () => {
title: 'WrongPart', 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', () => { it('should return true when title matches regexp with an explicit title value', () => {
@@ -94,7 +104,7 @@ describe('MediaMatcher', () => {
// title is undefined. // 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', () => { it('should return false when title does not match regexp', () => {
@@ -107,7 +117,7 @@ describe('MediaMatcher', () => {
title: 'ImportantPart', 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', () => { it('should return false when regexp is provided but does not extract the required group', () => {
@@ -120,14 +130,120 @@ describe('MediaMatcher', () => {
title: 'ImportantPart', 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)', () => { it('should return true when no regexp and no matcher.title (matches any title)', () => {
const mediaMatcher = new MediaMatcher(); const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Any Title Will Do', false); const media = createMediaItem('Any Title Will Do', false);
const matchers: Matcher[] = [{ type: 'title' }]; 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 One' }, // Pass
{ type: 'title', title: 'Test Media Two' }, // Fail { 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', () => { it('should return true if all multiple matchers pass', () => {
@@ -152,7 +268,7 @@ describe('MediaMatcher', () => {
title: 'Test Media One', 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', () => { it('should ignore matchers of unknown types', () => {
@@ -160,7 +276,7 @@ describe('MediaMatcher', () => {
const media = createMediaItem('Test Media', false); const media = createMediaItem('Test Media', false);
const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }]; 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 folder: FolderConfig = createFolder();
const query: FolderQuery = { const query: FolderQuery = {
folder, folder,
path: [{ id: 'media-source://' }], path: [{ ha: { id: 'media-source://' } }],
}; };
const executor = mock<FoldersExecutor>(); const executor = mock<FoldersExecutor>();
@@ -146,7 +146,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder(); const folder: FolderConfig = createFolder();
const query: FolderQuery = { const query: FolderQuery = {
folder, folder,
path: [{ id: 'media-source://' }], path: [{ ha: { id: 'media-source://' } }],
}; };
const executor = mock<FoldersExecutor>(); const executor = mock<FoldersExecutor>();
@@ -162,7 +162,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder(); const folder: FolderConfig = createFolder();
const query: FolderQuery = { const query: FolderQuery = {
folder, folder,
path: [{ id: 'media-source://' }], path: [{ ha: { id: 'media-source://' } }],
}; };
const executor = mock<FoldersExecutor>(); const executor = mock<FoldersExecutor>();
@@ -187,15 +187,23 @@ describe('FoldersManager', () => {
const manager = new FoldersManager(api, executor); const manager = new FoldersManager(api, executor);
const folder = createFolder({ id: 'folder-1' }); const folder = createFolder({ id: 'folder-1' });
const conditionState = {};
const engineOptions = {}; const engineOptions = {};
const query: FolderQuery = { const query: FolderQuery = {
folder, 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 () => { it('should not expand folder with hass', async () => {
@@ -205,7 +213,10 @@ describe('FoldersManager', () => {
const folder = createFolder({ id: 'folder-1' }); const folder = createFolder({ id: 'folder-1' });
expect( expect(
await manager.expandFolder({ folder, path: [{ id: 'media-source://' }] }), await manager.expandFolder({
folder,
path: [{ ha: { id: 'media-source://' } }],
}),
).toBeNull(); ).toBeNull();
expect(executor.expandFolder).not.toBeCalled(); 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 { NonEmptyTuple } from 'type-fest';
import { describe, expect, it } from 'vitest';
import {
matcherSchema,
transformPathURLToPathArray,
} from '../../../src/config/schema/folders';
describe('transformURLToMediaSourceRoot', () => { describe('transformURLToMediaSourceRoot', () => {
const prefixes: NonEmptyTuple<string>[] = [ 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',
},
],
});
});
});