diff --git a/docs/configuration/folders.md b/docs/configuration/folders.md index 9aa7f62c..1aaa82ad 100644 --- a/docs/configuration/folders.md +++ b/docs/configuration/folders.md @@ -72,6 +72,27 @@ 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. +?> The higher in the path you can match, the more performant the query. + +##### Matcher: `date` / `startdate` + +Match if the media was started more recently than the provided date information. + +?> Matching based on date requires the media has been parsed with the [`date` parser](#matcher-date--startdate) somewhere above or equal to the position of the matcher in the `path` hierarchy. + +```yaml +type: date +# [...] +``` + +| Parameter | Default | Description | +| --------------- | ------- | ------------------------------------------ | +| `since.minutes` | 0 | Media no older than this many minutes ago. | +| `since.hours` | 0 | Media no older than this many hours ago. | +| `since.days` | 0 | Media no older than this many days ago. | +| `since.months` | 0 | Media no older than this many months ago. | +| `since.years` | 0 | Media no older than this many years ago. | + ##### Matcher: `or` Match if any single matcher matches. @@ -300,4 +321,14 @@ folders: - matchers: - type: template value_template: "{{ acc.media.title == now().strftime('%Y/%-m/%d') }}" + - parsers: + - type: date + - matchers: + - type: date + since: + minutes: 1 + hours: 2 + days: 3 + months: 4 + years: 5 ``` diff --git a/docs/examples.md b/docs/examples.md index 155467cd..6f193c65 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -373,9 +373,10 @@ folders: - {} ``` -### Folder Parsing +### Folder date parsing and matching -This example parses dates from a folder, and times from the media items themselves. +This example parses dates from a folder, and matches only those dates in the +last two days. It then parses times from the media items themselves. ```yaml type: custom:advanced-camera-card @@ -394,13 +395,68 @@ folders: title: Low # Parses the date out of the next level (auto-detected format). - parsers: - - type: startdate + - type: date + matchers: + - type: date + since: + days: 2 # Parses the time out of the items themselves (user-specified format). - parsers: - - type: startdate + - type: date format: 'HH:mm:ss' ``` +#### Folder date matching by `template` + +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". + +?> Using a `date` matcher (as above) should be preferred for matching dates, +this example is included for illustration. + +```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' + - 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') }}" + - {} +``` + +### Folder Paths + +This example starts with the `media-source://frigate` folder, and looks for a +precisely titled `Clips [my-instance]` folder within that. The resulting media +will be the contents of that folder (if found). + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.office +folders: + - type: ha + ha: + path: + - id: 'media-source://frigate' + - title: 'Clips [my-instance]' +``` + ### Folder URLs This example uses the `url` parameter to establish the root of the query. Within @@ -426,57 +482,6 @@ 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 -precisely titled `Clips [my-instance]` folder within that. The resulting media -will be the contents of that folder (if found). - -```yaml -type: custom:advanced-camera-card -cameras: - - camera_entity: camera.office -folders: - - type: ha - ha: - path: - - id: 'media-source://frigate' - - title: 'Clips [my-instance]' -``` - ## Human interaction This example will automatically use a HD live substream when diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts index 7f0da583..4342a970 100644 --- a/src/card-controller/folders/ha/engine.ts +++ b/src/card-controller/folders/ha/engine.ts @@ -164,7 +164,7 @@ export class HAFoldersEngine implements FoldersEngine { this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers), ...(nextComponent && { - matcher: (media: BrowseMedia) => + matcher: (media: RichBrowseMedia) => this._mediaMatcher.match(hass, media, { matchers: nextComponent.ha?.matchers, // Set foldersOnly to true if there are more stages in the path, diff --git a/src/card-controller/folders/ha/media-matcher.ts b/src/card-controller/folders/ha/media-matcher.ts index 7c9bc95f..f3668538 100644 --- a/src/card-controller/folders/ha/media-matcher.ts +++ b/src/card-controller/folders/ha/media-matcher.ts @@ -1,6 +1,13 @@ +import { sub } from 'date-fns'; import { ConditionState } from '../../../conditions/types'; -import { Matcher, TemplateMatcher, TitleMatcher } from '../../../config/schema/folders'; -import { BrowseMedia } from '../../../ha/browse-media/types'; +import { + DateMatcher, + Matcher, + StartDateMatcher, + TemplateMatcher, + TitleMatcher, +} from '../../../config/schema/folders'; +import { BrowseMediaMetadata, RichBrowseMedia } from '../../../ha/browse-media/types'; import { HomeAssistant } from '../../../ha/types'; import { regexpExtract } from '../../../utils/regexp-extract'; import { TemplateRenderer } from '../../templates'; @@ -11,7 +18,7 @@ export class MediaMatcher { public match( hass: HomeAssistant, - media: BrowseMedia, + media: RichBrowseMedia, options?: { foldersOnly?: boolean; matchers?: Matcher[]; @@ -24,16 +31,25 @@ export class MediaMatcher { for (const matcher of options?.matchers ?? []) { switch (matcher.type) { + case 'date': + case 'startdate': + if (!this._matchStartDate(matcher, media)) { + return false; + } + break; + 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) => @@ -53,10 +69,28 @@ export class MediaMatcher { return true; } + private _matchStartDate( + matcher: DateMatcher | StartDateMatcher, + media: RichBrowseMedia, + ): boolean { + const startDate = media._metadata?.startDate; + return ( + !!startDate && + startDate >= + sub(new Date(), { + years: matcher.since.years ?? 0, + months: matcher.since.months ?? 0, + days: matcher.since.days ?? 0, + hours: matcher.since.hours ?? 0, + minutes: matcher.since.minutes ?? 0, + }) + ); + } + private _matchTemplate( hass: HomeAssistant, matcher: TemplateMatcher, - media: BrowseMedia, + media: RichBrowseMedia, conditionState?: ConditionState, ): boolean { return ( @@ -70,7 +104,10 @@ export class MediaMatcher { ); } - private _matchTitle(matcher: TitleMatcher, media: BrowseMedia): boolean { + private _matchTitle( + matcher: TitleMatcher, + media: RichBrowseMedia, + ): boolean { const valueToMatch = matcher.regexp ? regexpExtract(matcher.regexp, media.title, { groupName: REGEXP_GROUP_VALUE_KEY }) : media.title; diff --git a/src/config/schema/folders.ts b/src/config/schema/folders.ts index fdfe9a6c..b23a27ba 100644 --- a/src/config/schema/folders.ts +++ b/src/config/schema/folders.ts @@ -14,12 +14,10 @@ const folderConfigDefault = { ha: {}, }; -const parserBaseSchema = z.object({ - regexp: regexSchema.optional(), -}); -const startdateParserSchema = parserBaseSchema.extend({ +const startdateParserSchema = z.object({ type: z.literal('startdate'), format: z.string().optional(), + regexp: regexSchema.optional(), }); // Simple alias date -> startdate. const dateParserSchema = startdateParserSchema.extend({ @@ -31,13 +29,31 @@ const parserSchema = z.discriminatedUnion('type', [ ]); export type Parser = z.infer; -const templateMatcherSchema = parserBaseSchema.extend({ +const startDateMatcherSchema = z.object({ + type: z.literal('startdate'), + since: z.object({ + years: z.number().int().min(0).optional(), + months: z.number().int().min(0).optional(), + days: z.number().int().min(0).optional(), + hours: z.number().int().min(0).optional(), + minutes: z.number().int().min(0).optional(), + }), +}); +export type StartDateMatcher = z.infer; + +// Simple alias date -> startdate. +const dateMatcherSchema = startDateMatcherSchema.extend({ + type: z.literal('date'), +}); +export type DateMatcher = z.infer; + +const templateMatcherSchema = z.object({ type: z.literal('template'), value_template: z.string(), }); export type TemplateMatcher = z.infer; -const titleMatcherSchema = parserBaseSchema.extend({ +const titleMatcherSchema = z.object({ type: z.literal('title'), regexp: regexSchema.optional(), title: z.string().optional(), @@ -53,7 +69,9 @@ const orMatcherSchema: z.ZodSchema = z.object({ matchers: z.array(z.lazy(() => matcherSchema)), }); export const matcherSchema = z.union([ + dateMatcherSchema, orMatcherSchema, + startDateMatcherSchema, templateMatcherSchema, titleMatcherSchema, ]); diff --git a/tests/card-controller/folders/ha/media-matcher.test.ts b/tests/card-controller/folders/ha/media-matcher.test.ts index dd0ccbb1..9c51e05b 100644 --- a/tests/card-controller/folders/ha/media-matcher.test.ts +++ b/tests/card-controller/folders/ha/media-matcher.test.ts @@ -2,8 +2,12 @@ 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 { + BrowseMediaMetadata, + RichBrowseMedia, +} from '../../../../src/ha/browse-media/types'; import { createHASS } from '../../../test-utils'; +import { sub } from 'date-fns'; vi.mock('ha-nunjucks'); @@ -11,9 +15,9 @@ describe('MediaMatcher', () => { describe('match', () => { const createMediaItem = ( title: string, - can_expand: boolean, + can_expand = false, media_class = 'image', - ): BrowseMedia => ({ + ): RichBrowseMedia => ({ title, media_class, media_content_type: media_class === 'directory' ? 'directory' : 'image/jpeg', @@ -25,7 +29,7 @@ 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); + const media = createMediaItem('Test File'); expect( mediaMatcher.match(createHASS(), media, { matchers: [], foldersOnly: true }), ).toBe(false); @@ -41,13 +45,13 @@ describe('MediaMatcher', () => { it('should return true if matchers array is empty', () => { const mediaMatcher = new MediaMatcher(); - const media = createMediaItem('Test Media', false); + const media = createMediaItem('Test Media'); 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); + const media = createMediaItem('Test Media'); expect(mediaMatcher.match(createHASS(), media, { matchers: undefined })).toBe( true, ); @@ -56,21 +60,21 @@ describe('MediaMatcher', () => { describe('with title matcher', () => { it('should return true when title matches exactly', () => { const mediaMatcher = new MediaMatcher(); - const media = createMediaItem('Exact Title', false); + const media = createMediaItem('Exact Title'); const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; 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 media = createMediaItem('DOES NOT MATCH'); const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; expect(mediaMatcher.match(createHASS(), 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 media = createMediaItem('Prefix-ImportantPart-Suffix'); const matchers: Matcher[] = [ { type: 'title', @@ -83,7 +87,7 @@ describe('MediaMatcher', () => { 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 media = createMediaItem('Prefix-ImportantPart-Suffix'); const matchers: Matcher[] = [ { type: 'title', @@ -96,7 +100,7 @@ describe('MediaMatcher', () => { 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 media = createMediaItem('Prefix-ImportantPart-Suffix'); const matchers: Matcher[] = [ { type: 'title', @@ -109,7 +113,7 @@ describe('MediaMatcher', () => { it('should return false when title does not match regexp', () => { const mediaMatcher = new MediaMatcher(); - const media = createMediaItem('Unrelated Title', false); + const media = createMediaItem('Unrelated Title'); const matchers: Matcher[] = [ { type: 'title', @@ -122,7 +126,7 @@ describe('MediaMatcher', () => { 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 media = createMediaItem('Prefix-ImportantPart-Suffix'); const matchers: Matcher[] = [ { type: 'title', @@ -135,7 +139,7 @@ describe('MediaMatcher', () => { 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 media = createMediaItem('Any Title Will Do'); const matchers: Matcher[] = [{ type: 'title' }]; expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true); }); @@ -145,7 +149,7 @@ describe('MediaMatcher', () => { it('should return true when template value matches', () => { const mediaMatcher = new MediaMatcher(); const title = 'Any Title Will Do'; - const media = createMediaItem(title, false); + const media = createMediaItem(title); vi.mocked(renderTemplate).mockReturnValue(true); @@ -181,7 +185,7 @@ describe('MediaMatcher', () => { 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); + const media = createMediaItem(title); vi.mocked(renderTemplate).mockReturnValue(false); @@ -218,7 +222,7 @@ describe('MediaMatcher', () => { 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 media = createMediaItem('Test Media'); const matcher: Matcher = { type: 'or', matchers: [ @@ -233,7 +237,7 @@ describe('MediaMatcher', () => { it('should return false if no sub-matcher matches', () => { const mediaMatcher = new MediaMatcher(); - const media = createMediaItem('Test Media', false); + const media = createMediaItem('Test Media'); const matcher: Matcher = { type: 'or', matchers: [ @@ -247,9 +251,74 @@ describe('MediaMatcher', () => { }); }); + describe('with date matcher', () => { + it('should not match without metadata', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media'); + const matcher: Matcher = { + type: 'date', + since: { days: 1 }, + }; + expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe( + false, + ); + }); + + it.each([ + [ + { + type: 'date' as const, + since: { days: 2, minutes: 1 }, + }, + ], + [ + { + type: 'date' as const, + since: { days: 2, hours: 1 }, + }, + ], + [ + { + type: 'date' as const, + since: { months: 1 }, + }, + ], + [ + { + type: 'date' as const, + since: { years: 1 }, + }, + ], + ])('should match with date more recent than matcher %s', (matcher: Matcher) => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media'); + media._metadata = { + startDate: sub(new Date(), { days: 1 }), + }; + expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe( + true, + ); + }); + + it('should not match with date less recent than matcher', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media'); + media._metadata = { + startDate: sub(new Date(), { days: 2 }), + }; + const matcher: Matcher = { + type: 'date', + since: { days: 1 }, + }; + expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe( + false, + ); + }); + }); + it('should return false if one of multiple matchers fails', () => { const mediaMatcher = new MediaMatcher(); - const media = createMediaItem('Test Media One', false); + const media = createMediaItem('Test Media One'); const matchers: Matcher[] = [ { type: 'title', title: 'Test Media One' }, // Pass { type: 'title', title: 'Test Media Two' }, // Fail @@ -259,7 +328,7 @@ describe('MediaMatcher', () => { it('should return true if all multiple matchers pass', () => { const mediaMatcher = new MediaMatcher(); - const media = createMediaItem('Test Media One', false); + const media = createMediaItem('Test Media One'); const matchers: Matcher[] = [ { type: 'title', title: 'Test Media One' }, { @@ -273,7 +342,7 @@ describe('MediaMatcher', () => { it('should ignore matchers of unknown types', () => { const mediaMatcher = new MediaMatcher(); - const media = createMediaItem('Test Media', false); + const media = createMediaItem('Test Media'); const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }]; expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);