diff --git a/docs/configuration/folders.md b/docs/configuration/folders.md index fedf89e3..9f93ee13 100644 --- a/docs/configuration/folders.md +++ b/docs/configuration/folders.md @@ -21,7 +21,7 @@ folders: ## `ha` -Used to specify a Home Assistant media folder. +Used to specify a path to Home Assistant media. ```yaml folders: @@ -30,19 +30,29 @@ folders: # [...] ``` -| Option | Default | Description | -| ------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `url` | | An optional Home Assistant `Media` browser URL to use as the query base. If `path` is also specified, those matchers are applied against folders "below" the folder specified in `url`. | -| `path` | [`{ id: media-source:// }`] | An optional array of matchers to dynamically compare against the Home Assistant media folder hierarchy. See below. | +| Option | Default | Description | +| ------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | | An optional Home Assistant `Media` browser URL to use as the query base. If `path` is also specified, those matchers/parsers are applied against folders "below" the folder specified in `url`. | +| `path` | [`{ id: media-source:// }`] | An optional array of parsers and matchers to dynamically compare and extract metadata from the Home Assistant media folder hierarchy. See below. | ?> `url` is never fetched, nor sent over the network. It is only processed locally in your browser. The host part of the URL can optionally be removed. ### `path` -An array of matchers to navigate "down" a folder hierarchy. If `url` is also -specified, matchers are applied starting at that folder, otherwise they are -applied at the media source root (i.e. `media-source://`). +An array of values that represents the path to a Home Assistant media item, e.g. +a media item at a path of `one/two/three` would be represented by a path array +of length three. + +| Option | Default | Description | +| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | | An optional exact media item to select, usually the parent of where `matchers` and `parsers` should apply. | +| `matchers` | | An optional array of matchers to evaluate whether to return a given media item. If no matcher is specified, everything in the given folder matches. See below. | +| `parsers` | | An optional array of parsers that extract data out of a media item. See below. | + +If `url` is also specified, parsers/matchers are applied starting at that +folder, otherwise they are applied from the media source root (i.e. +`media-source://`). ```yaml folders: @@ -52,21 +62,93 @@ folders: # [...] ``` -| Option | Default | Description | -| ---------- | ------- | ------------------------------------------------------ | -| `id` | | An optional media source `id` to match against. | -| `title` | | An optional title name to match against. | -| `title_re` | | An optional title regular expression to match against. | +?> To match everything at a given level whilst parsing nothing would simply be +represented by an empty object `{}` -?> Specifying multiple `path` matchers (other than `id`) requires a query at -each level of the folder hierarchy and is slower than directly specifying the -media source `id` (if known) or the `url` of the folder. +#### Matchers -#### Examples +Matches are used to match a given media item. Multiple matchers may be specified +to perform multiple tests. A given match may match multiple items. If an item +does not match, it will not be returned to the user nor (in case of subfolders) +feature in future traversals. -See [Folder Examples](../examples.md?id=folders). +##### Matcher: `title` -#### Understanding Media Source IDs and "parent folders" +Match against the media item title. + +```yaml +type: title +# [...] +``` + +| Parameter | Description | +| --------- | ---------------------------------------------------------------------- | +| `type` | Must be `title`. | +| `regexp` | An optional regular expression to matches against the title. | +| `title` | An optional exact value (case-sensitive) to matches against the title. | + +#### Parsers + +Parsers are used to extract data from a media item (e.g. an event start time). +Parsed data is propagated down the hierarcy, e.g. a given media item inherits +the metadata of its parents. + +##### Parser `date` / `startdate` + +Parses a start date from a media title. `date` is an convenient alias for +`startdate`. + +```yaml +type: date +# [...] +``` + +| Parameter | Description | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | Must be `date` or `startdate`. | +| `format` | A [`date-fns` format string](https://date-fns.org/docs/parse). If unspecified, [`any-date-parser`](https://www.npmjs.com/package/any-date-parser) is used to parse a date and/or time which covers many common cases. In the event of missing or inaccurate metadata, specifying a precise format may help. | +| `regexp` | An optional regular expression to first match the title against before parsing. May be used to match against a subset of the string, see [tip below](#regular-expression-matching). | + +## Advanced + +### Regular Expression Matching + +For `matchers` and `parsers` that support the `regexp` option, it may be used to +compare against only a portion of the media title. By default, that portion is +whatever part of the title matches the given regexp. For extra-precision, use a +named regexp group called `value`. + +For example, pulling time values out of a media title, before parsing them with +a `HH:mm:ss` format. + +```yaml +parsers: + - type: date + regexp: "\d{2}:\d{2}:\d{2}" + format: HH:mm:ss +``` + +Similarly, this example uses a named group to refer to the text at the end of +the title. + +```yaml +parsers: + - type: date + regexp: '^Time: (?.*)+' + format: HH:mm:ss +``` + +In this contrived example, the regular expression is used to extract either +`Low` or `High` from the title, and then match only the `High` entry. + +```yaml +matchers: + - type: title + regexp: '(?Low|High) Resolution' + title: High +``` + +### Understanding Media Source IDs and "parent folders" Home Assistant Media Source IDs are typically long integration-specific non-user friendly strings that refer to a media item, or folder of media items. Media @@ -74,6 +156,93 @@ source "folders" do not have an intrinsic parent as with filesystem folders, rather a trail is built as the user navigates "downwards" -- but anything could theoretically be the parent of anything. +## Worked Example + +Imagine a media folder hierarchy that starts with a choice of resolution (Low or +High). Lets start by specifying a basic folder referring to the URL of the Home +Assistant Media Browser for that folder (via copy and paste of the URL from +another browser window with the folder open): + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 +``` + +The result: + +![](../images/folder-hierarchy-1.png 'Folder Hierarchy 1 :size=400') + +Now lets include selecting the High resolution folder: + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 + # Added below: + path: + - matchers: + - type: title + title: High resolution +``` + +The result: + +![](../images/folder-hierarchy-2.png 'Folder Hierarchy 2 :size=400') + +The next step is to navigate down to the date folder, parsing the date as we go +(auto-detecting the format): + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 + path: + - matchers: + - type: title + title: High resolution + # Added below: + - parsers: + - type: startdate +``` + +The result: + +![](../images/folder-hierarchy-3.png 'Folder Hierarchy 3 :size=400') + +The final step is to navigate down to the media item themselves, automatically parsing the time out of them: + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 + path: + - matchers: + - type: title + title: High resolution + - parsers: + - type: startdate + # Added below: + - parsers: + - type: startdate +``` + +The final result: + +![](../images/folder-hierarchy-4.png 'Folder Hierarchy 2 :size=400') + +### Other Examples + +See [Folder Examples](../examples.md?id=folders). + ## Fully expanded reference [](common/expanded-warning.md ':include') @@ -85,7 +254,15 @@ folders: url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate path: - id: 'media-source://' - - title: 'Frigate' - - title_re: 'Clips.*' - - title_re: 'Person.*' + - matchers: + - type: title + regexp: (?.*) resolution + title: Low + - parsers: + - type: date + format: yyyy/MM/dd + - parsers: + - type: startdate + format: HH:mm:ss + regexp: 'File (?.*)' ``` diff --git a/docs/examples.md b/docs/examples.md index c7e6b7ed..24cd64e1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -356,8 +356,8 @@ folders: ### Folder within the Home Assistant default root This example applies a title match against the Home Assistant media root folder -looking for a folder entitled `Frigate`. The resulting media will be the -contents of that folder (if found). +looking for a folder entitled `Frigate`, and shows all items within it (since no +matcher is specified at the lowest level). ```yaml type: custom:advanced-camera-card @@ -367,7 +367,38 @@ folders: - type: ha ha: path: - - title: 'Frigate' + - matchers: + - type: title + title: 'Frigate' + - {} +``` + +### Folder Parsing + +This example parses dates from a folder, and times from the media items themselves. + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.office +folders: + - type: ha + ha: + url: >- + https://ha.ondu.org/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XHYTNH77WE3C654K03KX1F%7C0 + path: + # Matches against the "Low resolution" folder. + - matchers: + - type: title + regexp: (?.*) resolution + title: Low + # Parses the date out of the next level (auto-detected format). + - parsers: + - type: startdate + # Parses the time out of the items themselves (user-specified format). + - parsers: + - type: startdate + format: 'HH:mm:ss' ``` ### Folder URLs @@ -387,8 +418,12 @@ folders: ha: url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate path: - - title_re: 'Clips.*' - - title_re: 'Person.*' + - matchers: + - type: title + regexp: 'Clips.*' + - matchers: + - type: title + regexp: 'Person.*' ``` ### Folder Paths diff --git a/docs/images/folder-hierarchy-1.png b/docs/images/folder-hierarchy-1.png new file mode 100644 index 00000000..c4ec821c Binary files /dev/null and b/docs/images/folder-hierarchy-1.png differ diff --git a/docs/images/folder-hierarchy-2.png b/docs/images/folder-hierarchy-2.png new file mode 100644 index 00000000..ef5f47a1 Binary files /dev/null and b/docs/images/folder-hierarchy-2.png differ diff --git a/docs/images/folder-hierarchy-3.png b/docs/images/folder-hierarchy-3.png new file mode 100644 index 00000000..bc9f3d83 Binary files /dev/null and b/docs/images/folder-hierarchy-3.png differ diff --git a/docs/images/folder-hierarchy-4.png b/docs/images/folder-hierarchy-4.png new file mode 100644 index 00000000..f0b73151 Binary files /dev/null and b/docs/images/folder-hierarchy-4.png differ diff --git a/package.json b/package.json index ddf53742..ac627455 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@lit-labs/scoped-registry-mixin": "^1.0.3", "@lit-labs/task": "^1.1.3", "@types/bluebird": "^3.5.42", + "any-date-parser": "^2.2.0", "component-emitter": "^1.3.1", "compute-scroll-into-view": "^3.1.1", "crypto": "^1.0.1", diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 0ba24707..1cb50109 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -152,7 +152,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine } return { cameraID: cameraID, - startDate: startDate, + startDate, endDate: parent?._metadata?.endDate ?? endOfDay(startDate), }; } @@ -389,8 +389,8 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine engineOptions, ); for (const dayDirectory of directories ?? []) { - if (dayDirectory._metadata) { - days.add(formatDate(dayDirectory._metadata?.startDate)); + if (dayDirectory._metadata?.startDate) { + days.add(formatDate(dayDirectory._metadata.startDate)); } } }; diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index aa4c557c..7ff0dba1 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -2,7 +2,6 @@ import { add, endOfDay, parse, startOfDay } from 'date-fns'; import { orderBy } from 'lodash-es'; import { CameraConfig } from '../../config/schema/cameras'; import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media'; -import { sortMediaByStartDate } from '../../ha/browse-media/sort-browse-media-by-start-date'; import { BROWSE_MEDIA_CACHE_SECONDS, BrowseMedia, @@ -213,8 +212,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { matcher: (media: RichBrowseMedia) => media.can_expand && isMediaWithinDates(media, matchOptions?.start, matchOptions?.end), - sorter: (media: RichBrowseMedia[]) => - sortMediaByStartDate(media), }, ], { @@ -278,8 +275,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { matcher: (media: RichBrowseMedia) => !media.can_expand && isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end), - sorter: (media: RichBrowseMedia[]) => - sortMediaByStartDate(media), }, ], { @@ -357,8 +352,8 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { for (const dayDirectory of directories ?? []) { /* istanbul ignore next: This situation cannot happen as the directory will not match without metadata -- @preserve */ - if (dayDirectory._metadata) { - days.add(formatDate(dayDirectory._metadata?.startDate)); + if (dayDirectory._metadata?.startDate) { + days.add(formatDate(dayDirectory._metadata.startDate)); } } }; diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts index 1fed5a20..1fc8a896 100644 --- a/src/card-controller/folders/ha/engine.ts +++ b/src/card-controller/folders/ha/engine.ts @@ -7,7 +7,13 @@ import { HAFolderPathComponent, } from '../../../config/schema/folders'; import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media'; -import { BrowseMedia, BrowseMediaCache } from '../../../ha/browse-media/types'; +import { BrowseMediaViewFolder } from '../../../ha/browse-media/item'; +import { + BrowseMedia, + BrowseMediaCache, + BrowseMediaMetadata, + RichBrowseMedia, +} from '../../../ha/browse-media/types'; import { BrowseMediaStep, BrowseMediaTarget, @@ -26,13 +32,24 @@ import { FolderQuery, FoldersEngine, } from '../types'; +import { MediaMatcher } from './media-matcher'; +import { MetadataGenerator } from './metadata-generator.js'; export class HAFoldersEngine implements FoldersEngine { private _browseMediaManager: BrowseMediaWalker; - private _cache = new BrowseMediaCache(); + private _cache = new BrowseMediaCache(); - public constructor(browseMediaManager?: BrowseMediaWalker) { - this._browseMediaManager = browseMediaManager ?? new BrowseMediaWalker(); + private _metadataGenerator: MetadataGenerator; + private _mediaMatcher: MediaMatcher; + + public constructor(options?: { + browseMediaManager?: BrowseMediaWalker; + metadataGenerator?: MetadataGenerator; + mediaMatcher?: MediaMatcher; + }) { + this._browseMediaManager = options?.browseMediaManager ?? new BrowseMediaWalker(); + this._metadataGenerator = options?.metadataGenerator ?? new MetadataGenerator(); + this._mediaMatcher = options?.mediaMatcher ?? new MediaMatcher(); } public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null { @@ -75,6 +92,23 @@ export class HAFoldersEngine implements FoldersEngine { }; } + private getDefaultFolderPathComponents( + haFolderConfig?: HAFolderConfig, + ): NonEmptyTuple { + const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id; + + const path: HAFolderPathComponent[] = [ + ...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []), + ...(haFolderConfig?.url ?? []), + ...(haFolderConfig?.path ?? []), + ]; + + return path.map((component) => ({ ha: component })) as [ + FolderPathComponent, + ...FolderPathComponent[], + ]; + } + public async expandFolder( hass: HomeAssistant, query: FolderQuery, @@ -89,11 +123,16 @@ export class HAFoldersEngine implements FoldersEngine { // Search through the path components from the start to find the last // component with a precise media source id, which is where the queries // start (and may drill down from). - let start: string | null = null; + let start: string | RichBrowseMedia | null = null; while (pathComponents.length > 0) { - const id = pathComponents[0]?.id; - if (id) { - start = id; + const folderBrowseMedia = + pathComponents[0]?.folder instanceof BrowseMediaViewFolder + ? pathComponents[0].folder.getBrowseMedia() + : null; + + const validStart = folderBrowseMedia ?? pathComponents[0]?.ha?.id ?? null; + if (validStart) { + start = validStart; pathComponents.shift(); } else { break; @@ -106,37 +145,38 @@ export class HAFoldersEngine implements FoldersEngine { return null; } - // This matcher matches a browse media against a given path component. - const componentMatcher = ( - media: BrowseMedia, - component?: FolderPathComponent, - ): boolean => { - return ( - !component || - (media.can_expand && - (component.ha?.title === media.title || - (component.ha?.title_re && - new RegExp(component.ha.title_re).test(media.title)) || - component.id === media.media_content_id)) - ); - }; + await this._metadataGenerator.prepare( + pathComponents.flatMap((component) => component.ha?.parsers ?? []), + ); // Generate a walk step, optionally matching against the next path component // (if any), otherwise just returning all the media at this level. - const generateStep = (targets: BrowseMediaTarget[]): BrowseMediaStep[] => { + const generateStep = ( + targets: BrowseMediaTarget[], + ): BrowseMediaStep[] => { const nextComponent = pathComponents.shift(); return [ { targets, + metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) => + this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers), + ...(nextComponent && { - matcher: (media: BrowseMedia) => componentMatcher(media, nextComponent), - advance: (targets) => generateStep(targets), + matcher: (media: BrowseMedia) => + this._mediaMatcher.match( + media, + nextComponent.ha?.matchers, + // Set foldersOnly to true if there are more stages in the path, + // as by definition only folders can be matched at this point. + pathComponents.length > 0, + ), + advance: (targets) => (pathComponents.length ? generateStep(targets) : []), }), }, ]; }; - const browseMedia = await this._browseMediaManager.walk( + const browseMedia = await this._browseMediaManager.walk( hass, generateStep([start]), { @@ -148,33 +188,4 @@ export class HAFoldersEngine implements FoldersEngine { folder: query.folder, }); } - - private getDefaultFolderPathComponents( - haFolderConfig?: HAFolderConfig, - ): NonEmptyTuple { - const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id; - - const defaultPath = [ - ...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []), - ...(haFolderConfig?.url ?? []), - ...(haFolderConfig?.path ?? []), - ]; - - return defaultPath.map((component) => - this._convertHAPathComponentToFolderPathComponent(component), - ) as [FolderPathComponent, ...FolderPathComponent[]]; - } - - // Convert from the HA folder path component config schema to the general, - // which pulls `path` to the top level. - private _convertHAPathComponentToFolderPathComponent( - component: HAFolderPathComponent, - ): FolderPathComponent { - return { - id: component.id, - ha: { - ...component, - }, - }; - } } diff --git a/src/card-controller/folders/ha/media-matcher.ts b/src/card-controller/folders/ha/media-matcher.ts new file mode 100644 index 00000000..e9abc4d9 --- /dev/null +++ b/src/card-controller/folders/ha/media-matcher.ts @@ -0,0 +1,38 @@ +import { Matcher } from '../../../config/schema/folders'; +import { BrowseMedia } from '../../../ha/browse-media/types'; +import { regexpExtract } from '../../../utils/regexp-extract'; +import { REGEXP_GROUP_VALUE_KEY } from './types'; + +export class MediaMatcher { + public match(media: BrowseMedia, matchers?: Matcher[], foldersOnly = false): boolean { + if (foldersOnly && !media.can_expand) { + return false; + } + + for (const matcher of matchers ?? []) { + if (matcher.type === 'title') { + if (!this._matchTitle(matcher, media.title)) { + return false; + } + } + } + + return true; + } + + private _matchTitle(matcher: Matcher, src: string): boolean { + const valueToMatch = matcher.regexp + ? regexpExtract(matcher.regexp, src, { groupName: REGEXP_GROUP_VALUE_KEY }) + : src; + + if (!valueToMatch) { + return false; + } + + if (matcher.title) { + return valueToMatch === matcher.title; + } + + return true; + } +} diff --git a/src/card-controller/folders/ha/metadata-generator.ts b/src/card-controller/folders/ha/metadata-generator.ts new file mode 100644 index 00000000..dc322712 --- /dev/null +++ b/src/card-controller/folders/ha/metadata-generator.ts @@ -0,0 +1,99 @@ +import { parse } from 'date-fns'; +import { Parser } from '../../../config/schema/folders'; +import { + BrowseMedia, + BrowseMediaMetadata, + RichBrowseMedia, +} from '../../../ha/browse-media/types'; + +import parser from 'any-date-parser'; +import { isValidDate } from '../../../utils/basic'; +import { regexpExtract } from '../../../utils/regexp-extract'; +import { REGEXP_GROUP_VALUE_KEY } from './types'; + +export class MetadataGenerator { + protected _anyDateParser: typeof parser | null = null; + + public async prepare(parsers?: Parser[]): Promise { + if (this._anyDateParser) { + return; + } + + // Dynamically import the any-date-parser only if we have a parser that + // requires it, in order to save on bundle size. + if ( + parsers?.some( + (parser) => ['date', 'startdate'].includes(parser.type) && !parser.format, + ) + ) { + this._anyDateParser = (await import('any-date-parser')).default; + } + } + + public generate( + media: BrowseMedia, + parent?: RichBrowseMedia, + parsers?: Parser[], + ): BrowseMediaMetadata | null { + // Always propagate metadata from parent to children. + const metadata: BrowseMediaMetadata = { + ...parent?._metadata, + }; + + for (const parser of parsers ?? []) { + const valueToParse = parser.regexp + ? regexpExtract(parser.regexp, media.title, { + groupName: REGEXP_GROUP_VALUE_KEY, + }) + : media.title; + if (!valueToParse) { + continue; + } + if (parser.type === 'startdate' || parser.type === 'date') { + metadata.startDate = + this._parseDate(parser, valueToParse, parent?._metadata?.startDate) ?? + undefined; + } + } + + return Object.keys(metadata).length > 0 ? metadata : null; + } + + private _parseDate(parser: Parser, src: string, base?: Date): Date | undefined { + if (parser.format) { + return this._parseFormattedDate(parser.format, src, base); + } + return this._parseUnknownDate(src, base); + } + + private _parseFormattedDate( + format: string, + src: string, + base?: Date, + ): Date | undefined { + const result = parse(src, format, base ?? new Date()); + return isValidDate(result) ? result : undefined; + } + + private _parseUnknownDate(src: string, base?: Date): Date | undefined { + if (!this._anyDateParser) { + return undefined; + } + const result = this._anyDateParser.attempt(src); + if (!Object.keys(result).length) { + return undefined; + } + return this._anyDateParser.fromObject({ + ...(base && { + year: base.getFullYear(), + month: base.getMonth() + 1, + day: base.getDate(), + hour: base.getHours(), + minute: base.getMinutes(), + second: base.getSeconds(), + millisecond: base.getMilliseconds(), + }), + ...result, + }); + } +} diff --git a/src/card-controller/folders/ha/types.ts b/src/card-controller/folders/ha/types.ts new file mode 100644 index 00000000..83116b6a --- /dev/null +++ b/src/card-controller/folders/ha/types.ts @@ -0,0 +1 @@ +export const REGEXP_GROUP_VALUE_KEY = 'value'; diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts index b59145b4..03c0e7ae 100644 --- a/src/card-controller/folders/types.ts +++ b/src/card-controller/folders/types.ts @@ -4,7 +4,7 @@ import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; import { Endpoint } from '../../types'; import { AdvancedCameraCardError } from '../../types.js'; -import { ViewItem } from '../../view/item'; +import { ViewFolder, ViewItem } from '../../view/item'; import { ViewItemCapabilities } from '../../view/types'; // ==== @@ -22,8 +22,8 @@ export class FolderInitializationError extends AdvancedCameraCardError {} // ============ export type FolderPathComponent = { - id?: string; - ha?: Omit; + folder?: ViewFolder; + ha?: HAFolderPathComponent; }; export interface FolderQuery { diff --git a/src/components-lib/gallery/folder-gallery-controller.ts b/src/components-lib/gallery/folder-gallery-controller.ts index 5291157e..bbbcfff9 100644 --- a/src/components-lib/gallery/folder-gallery-controller.ts +++ b/src/components-lib/gallery/folder-gallery-controller.ts @@ -72,7 +72,7 @@ export class FolderGalleryController { params: { query: view.query.clone().setQuery({ folder: rawQuery.folder, - path: [...rawQuery.path, { id }], + path: [...rawQuery.path, { folder: item }], }), }, }); diff --git a/src/components-lib/thumbnail/details-controller.ts b/src/components-lib/thumbnail/details-controller.ts index c504fef9..7e912afb 100644 --- a/src/components-lib/thumbnail/details-controller.ts +++ b/src/components-lib/thumbnail/details-controller.ts @@ -135,10 +135,9 @@ export class ThumbnailDetailsController { : []), ]; - // To avoid duplication, if the event already has a structured 'what' and a - // starttime, the title is omitted from the details. - const includeTitle = - !ViewItemClassifier.isEvent(item) || !item?.getWhat()?.length || !startTime; + // To avoid duplication, if the event has a starttime, the title is omitted + // from the details. + const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime; this._details = [ ...(includeTitle && itemTitle ? [ diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 875d9ad4..c854168d 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -128,9 +128,8 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { QueryClassifier.isFolderQuery(query) && ViewItemClassifier.isFolder(item) ) { - const id = item.getID(); const rawQuery = query.getQuery(); - if (!id || !rawQuery) { + if (!rawQuery) { return; } @@ -138,7 +137,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { params: { query: query.clone().setQuery({ folder: rawQuery.folder, - path: [...(rawQuery.path ?? []), { id }], + path: [...(rawQuery.path ?? []), { folder: item }], }), }, }); diff --git a/src/config/schema/folders.ts b/src/config/schema/folders.ts index b7ed632e..d533256c 100644 --- a/src/config/schema/folders.ts +++ b/src/config/schema/folders.ts @@ -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://'; @@ -14,11 +14,35 @@ const folderConfigDefault = { ha: {}, }; +const parserBaseSchema = z.object({ + regexp: regexSchema.optional(), +}); +const startdateParserSchema = parserBaseSchema.extend({ + type: z.literal('startdate'), + format: z.string().optional(), +}); +// Simple alias date -> startdate. +const dateParserSchema = startdateParserSchema.extend({ + type: z.literal('date'), +}); +const parserSchema = z.discriminatedUnion('type', [ + dateParserSchema, + startdateParserSchema, +]); +export type Parser = z.infer; + +const titleMatcherSchema = parserBaseSchema.extend({ + type: z.literal('title'), + regexp: regexSchema.optional(), + title: z.string().optional(), +}); +const matcherSchema = z.discriminatedUnion('type', [titleMatcherSchema]); +export type Matcher = z.infer; + const haFolderPathComponentSchema = z.object({ id: z.string().optional(), - - title: z.string().optional(), - title_re: regexSchema.optional(), + parsers: parserSchema.array().optional(), + matchers: matcherSchema.array().optional(), }); export type HAFolderPathComponent = z.infer; @@ -50,9 +74,7 @@ export const transformPathURLToPathArray = ( for (const component of folderPath) { if (component.id && !component.id.startsWith(HA_MEDIA_SOURCE_ROOT)) { - throw new AdvancedCameraCardError( - `Could not parse valid media source URL: ${url}`, - ); + throw new AdvancedCameraCardError(`Could not parse media source URL: ${url}`); } } return folderPath; diff --git a/src/ha/browse-media/browse-media-to-view-media.ts b/src/ha/browse-media/browse-media-to-view-media.ts index 61d4d721..fbd3e5d1 100644 --- a/src/ha/browse-media/browse-media-to-view-media.ts +++ b/src/ha/browse-media/browse-media-to-view-media.ts @@ -13,9 +13,7 @@ export const getViewMediaFromBrowseMediaArray = ( ); }; -export const getViewItemsFromBrowseMediaArray = < - M extends BrowseMediaMetadata | undefined, ->( +export const getViewItemsFromBrowseMediaArray = ( browseMedia: RichBrowseMedia[], options?: ViewMediaSourceOptions, ): ViewItem[] => { diff --git a/src/ha/browse-media/item-factory.ts b/src/ha/browse-media/item-factory.ts index 0acc60f7..02a0ebbb 100644 --- a/src/ha/browse-media/item-factory.ts +++ b/src/ha/browse-media/item-factory.ts @@ -9,7 +9,7 @@ import { export class BrowseMediaViewItemFactory { static create( - browseMedia: RichBrowseMedia, + browseMedia: RichBrowseMedia, options?: ViewMediaSourceOptions, ): ViewItem | null { if (browseMedia.can_expand) { diff --git a/src/ha/browse-media/item.ts b/src/ha/browse-media/item.ts index 50e90c07..224f7585 100644 --- a/src/ha/browse-media/item.ts +++ b/src/ha/browse-media/item.ts @@ -10,7 +10,7 @@ import { ViewMediaSourceOptions, ViewMediaType, } from '../../view/item'; -import { BrowseMedia, BrowseMediaMetadata, RichBrowseMedia } from './types'; +import { BrowseMediaMetadata, RichBrowseMedia } from './types'; interface MediaClassBrowserSetting { icon: string; @@ -114,12 +114,19 @@ export class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMed } export class BrowseMediaViewFolder extends ViewFolder { - constructor(folder: FolderConfig, browseMedia: BrowseMedia) { + private _browseMedia: RichBrowseMedia; + + constructor(folder: FolderConfig, browseMedia: RichBrowseMedia) { super(folder, { id: browseMedia.media_content_id, icon: getIcon(browseMedia.children_media_class ?? browseMedia.media_class), title: browseMedia.title, thumbnail: browseMedia.thumbnail, }); + this._browseMedia = browseMedia; + } + + public getBrowseMedia(): RichBrowseMedia { + return this._browseMedia; } } diff --git a/src/ha/browse-media/sort-browse-media-by-start-date.ts b/src/ha/browse-media/sort-browse-media-by-start-date.ts deleted file mode 100644 index 853ac0fd..00000000 --- a/src/ha/browse-media/sort-browse-media-by-start-date.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { orderBy } from 'lodash-es'; -import { BrowseMediaMetadata, RichBrowseMedia } from './types'; - -export const sortMediaByStartDate = ( - media: RichBrowseMedia[], -): RichBrowseMedia[] => { - return orderBy(media, (media) => media._metadata?.startDate, 'desc'); -}; diff --git a/src/ha/browse-media/types.ts b/src/ha/browse-media/types.ts index 72db28c6..815f9748 100644 --- a/src/ha/browse-media/types.ts +++ b/src/ha/browse-media/types.ts @@ -2,9 +2,9 @@ import { z } from 'zod'; import { ExpiringEqualityCache } from '../../cache/expiring-cache'; export interface BrowseMediaMetadata { - cameraID: string; - startDate: Date; - endDate: Date; + cameraID?: string; + startDate?: Date; + endDate?: Date; what?: string[]; } // Recursive type, cannot use type interference: diff --git a/src/ha/browse-media/within-dates.ts b/src/ha/browse-media/within-dates.ts index d001f81d..febec2dd 100644 --- a/src/ha/browse-media/within-dates.ts +++ b/src/ha/browse-media/within-dates.ts @@ -16,7 +16,7 @@ export const isMediaWithinDates = ( end?: Date, ): boolean => { // If there's no metadata, nothing matches. - if (!media._metadata) { + if (!media._metadata?.startDate || !media._metadata?.endDate) { return false; } diff --git a/src/utils/regexp-extract.ts b/src/utils/regexp-extract.ts new file mode 100644 index 00000000..86720546 --- /dev/null +++ b/src/utils/regexp-extract.ts @@ -0,0 +1,22 @@ +/** + * Extracts a substring from a string using a regular expression pattern, if + * groupName / groupNumber is specified but not found in the result, the full + * match is returned. + */ +export const regexpExtract = ( + pattern: string | RegExp, + val: string, + options?: { + groupName?: string; + groupNumber?: number; + }, +): string | null => { + const match = val.match(pattern); + if (options?.groupName && match?.groups?.[options.groupName]) { + return match.groups[options.groupName]; + } + if (options?.groupNumber !== undefined && match?.[options.groupNumber]) { + return match[options.groupNumber]; + } + return match ? match[0] : null; +}; diff --git a/tests/card-controller/folders/ha/engine.test.ts b/tests/card-controller/folders/ha/engine.test.ts index 1f186a36..3c20349f 100644 --- a/tests/card-controller/folders/ha/engine.test.ts +++ b/tests/card-controller/folders/ha/engine.test.ts @@ -3,6 +3,8 @@ 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 { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item'; +import { browseMediaSchema } from '../../../../src/ha/browse-media/types'; import { getMediaDownloadPath } from '../../../../src/ha/download'; import { homeAssistantWSRequest } from '../../../../src/ha/ws-request'; import { Endpoint } from '../../../../src/types'; @@ -73,17 +75,6 @@ describe('HAFoldersEngine', () => { }); }); - describe('expandFolder', () => { - it('should reject folders of the wrong type', async () => { - const query = { - folder: { type: 'UNKNOWN' }, - } as unknown as FolderQuery; - const engine = new HAFoldersEngine(); - - expect(await engine.expandFolder(createHASS(), query)).toBeNull(); - }); - }); - describe('should generate default folder query', () => { it('should generate default folder query', () => { const folder: FolderConfig = { type: 'ha' }; @@ -92,7 +83,7 @@ describe('HAFoldersEngine', () => { const query = engine.generateDefaultFolderQuery(folder); expect(query).toEqual({ folder, - path: [{ id: 'media-source://', ha: { id: 'media-source://' } }], + path: [{ ha: { id: 'media-source://' } }], }); }); @@ -113,10 +104,7 @@ describe('HAFoldersEngine', () => { const engine = new HAFoldersEngine(); expect(engine.generateDefaultFolderQuery(folder)).toEqual({ folder, - path: [ - { id: 'media-source://1', ha: { id: 'media-source://1' } }, - { id: 'media-source://2', ha: { id: 'media-source://2' } }, - ], + path: [{ ha: { id: 'media-source://1' } }, { ha: { id: 'media-source://2' } }], }); }); @@ -125,28 +113,39 @@ describe('HAFoldersEngine', () => { const engine = new HAFoldersEngine(); expect(engine.generateDefaultFolderQuery(folder)).toEqual({ folder, - path: [{ id: 'media-source://', ha: { id: 'media-source://' } }], + path: [{ ha: { id: 'media-source://' } }], }); }); it('should add default media root as necessary', async () => { - const folder = createFolder({ ha: { path: [{ title: 'Frigate' }] } }); + const folder = createFolder({ + ha: { path: [{ matchers: [{ type: 'title', title: 'Frigate' }] }] }, + }); const engine = new HAFoldersEngine(); expect(engine.generateDefaultFolderQuery(folder)).toEqual({ folder, path: [ - { id: 'media-source://', ha: { id: 'media-source://' } }, - { id: undefined, ha: { title: 'Frigate' } }, + { ha: { id: 'media-source://' } }, + { ha: { matchers: [{ type: 'title', title: 'Frigate' }] } }, ], }); }); }); describe('should expand folder', () => { + it('should reject folders of the wrong type', async () => { + const query = { + folder: { type: 'UNKNOWN' }, + } as unknown as FolderQuery; + const engine = new HAFoldersEngine(); + + expect(await engine.expandFolder(createHASS(), query)).toBeNull(); + }); + it('should expand folder with cache by default', async () => { const query: FolderQuery = { folder: { type: 'ha' }, - path: [{ id: 'media-source://id' }], + path: [{ ha: { id: 'media-source://id' } }], }; vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce( @@ -183,7 +182,7 @@ describe('HAFoldersEngine', () => { it('should expand folder without cache when requested', async () => { const query: FolderQuery = { folder: { type: 'ha' }, - path: [{ id: 'media-source://id' }], + path: [{ ha: { id: 'media-source://id' } }], }; vi.mocked(homeAssistantWSRequest) @@ -221,150 +220,93 @@ describe('HAFoldersEngine', () => { expect(homeAssistantWSRequest).toBeCalledTimes(2); }); + it('should use id from browsemedia in folder in query', async () => { + const browseMedia = createBrowseMedia({ + media_content_id: 'media-source://id', + can_expand: true, + }); + + const query: FolderQuery = { + folder: { type: 'ha' }, + path: [ + { + folder: new BrowseMediaViewFolder(createFolder(), browseMedia), + }, + ], + }; + + vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce( + createBrowseMedia({ + media_content_id: 'media-source://id', + can_expand: true, + children: [], + }), + ); + + const hass = createHASS(); + const engine = new HAFoldersEngine(); + await engine.expandFolder(hass, query); + + expect(homeAssistantWSRequest).toBeCalledWith(hass, browseMediaSchema, { + type: 'media_source/browse_media', + media_content_id: 'media-source://id', + }); + }); + it('should not expand without a folder with an id', async () => { const query: FolderQuery = { folder: { type: 'ha' }, // There's no component in the query with an id to start from. - path: [{ ha: { title: 'Frigate' } }], + path: [{ ha: {} }], }; const engine = new HAFoldersEngine(); expect(await engine.expandFolder(createHASS(), query)).toBeNull(); }); - it('should expand folder with title based query', async () => { - const query: FolderQuery = { - folder: { type: 'ha' }, - path: [{ id: 'media-source://' }, { ha: { title: 'Frigate' } }], - }; + describe('should apply matchers', async () => { + it('should expand folder with title based query', async () => { + const query: FolderQuery = { + folder: { type: 'ha' }, + path: [ + { ha: { id: 'media-source://' } }, + { ha: { matchers: [{ type: 'title', title: 'Frigate' }] } }, + {}, + ], + }; - vi.mocked(homeAssistantWSRequest) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate', - title: 'Frigate', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/result', - title: 'Result', - }), - ], - }), - ); + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce( + createBrowseMedia({ + media_content_id: 'media-source://', + can_expand: true, + children: [ + createBrowseMedia({ + media_content_id: 'media-source://frigate', + title: 'Frigate', + can_expand: true, + }), + ], + }), + ) + .mockResolvedValueOnce( + createBrowseMedia({ + media_content_id: 'media-source://frigate', + can_expand: true, + children: [ + createBrowseMedia({ + media_content_id: 'media-source://frigate/result', + title: 'Result', + }), + ], + }), + ); - const engine = new HAFoldersEngine(); - const results = await engine.expandFolder(createHASS(), query); - expect(results?.length).toBe(1); - expect(results?.[0]).toBeInstanceOf(ViewMedia); - expect(results?.[0].getID()).toBe('media-source://frigate/result'); - }); - - it('should expand folder with title_re based query', async () => { - const query: FolderQuery = { - folder: { type: 'ha' }, - path: [{ id: 'media-source://' }, { ha: { title_re: 'Fri.*' } }], - }; - - vi.mocked(homeAssistantWSRequest) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate', - title: 'Frigate', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/result', - title: 'Result', - }), - ], - }), - ); - - const engine = new HAFoldersEngine(); - const results = await engine.expandFolder(createHASS(), query); - expect(results?.length).toBe(1); - expect(results?.[0]).toBeInstanceOf(ViewMedia); - expect(results?.[0].getID()).toBe('media-source://frigate/result'); - }); - - it('should expand folder with ha id based query', async () => { - const query: FolderQuery = { - folder: { type: 'ha' }, - path: [ - { id: 'media-source://' }, - { ha: { title: 'Frigate' } }, - { id: 'media-source://frigate/subdir' }, - ], - }; - - vi.mocked(homeAssistantWSRequest) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate', - title: 'Frigate', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/subdir', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate/subdir', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/subdir/result', - title: 'Result', - }), - ], - }), - ); - - const engine = new HAFoldersEngine(); - const results = await engine.expandFolder(createHASS(), query); - expect(results?.length).toBe(1); - expect(results?.[0]).toBeInstanceOf(ViewMedia); - expect(results?.[0].getID()).toBe('media-source://frigate/subdir/result'); + 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'); + }); }); }); }); diff --git a/tests/card-controller/folders/ha/media-matcher.test.ts b/tests/card-controller/folders/ha/media-matcher.test.ts new file mode 100644 index 00000000..6939149c --- /dev/null +++ b/tests/card-controller/folders/ha/media-matcher.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { MediaMatcher } from '../../../../src/card-controller/folders/ha/media-matcher'; +import { Matcher } from '../../../../src/config/schema/folders'; +import { BrowseMedia } from '../../../../src/ha/browse-media/types'; + +describe('MediaMatcher', () => { + describe('match', () => { + const createMediaItem = ( + title: string, + can_expand: boolean, + media_class = 'image', + ): BrowseMedia => ({ + title, + media_class, + media_content_type: media_class === 'directory' ? 'directory' : 'image/jpeg', + media_content_id: `${media_class}_${title.replace(/\s+/g, '_')}`, + can_play: media_class !== 'directory', + can_expand, + thumbnail: null, + }); + + it('should return false if foldersOnly is true and media.can_expand is false', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test File', false); + expect(mediaMatcher.match(media, [], true)).toBe(false); + }); + + it('should return true if foldersOnly is true and media.can_expand is true', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Folder', true, 'directory'); + expect(mediaMatcher.match(media, [], true)).toBe(true); + }); + + it('should return true if matchers array is empty', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media', false); + expect(mediaMatcher.match(media, [])).toBe(true); + }); + + it('should return true if matchers array is undefined', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media', false); + expect(mediaMatcher.match(media, undefined)).toBe(true); + }); + + describe('with title matcher', () => { + it('should return true when title matches exactly', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Exact Title', false); + const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + + it('should return false when title does not match exactly', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('DOES NOT MATCH', false); + const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return true when title matches regexp and extracted value matches matcher.title', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + title: 'ImportantPart', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + + it('should return false when title matches regexp but extracted value does not match matcher.title', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + title: 'WrongPart', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return true when title matches regexp with an explicit title value', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + // title is undefined. + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + + it('should return false when title does not match regexp', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Unrelated Title', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + title: 'ImportantPart', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return false when regexp is provided but does not extract the required group', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: `^Prefix-ImportantPart-Suffix$`, // No named group + title: 'ImportantPart', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return true when no regexp and no matcher.title (matches any title)', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Any Title Will Do', false); + const matchers: Matcher[] = [{ type: 'title' }]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + }); + + it('should return false if one of multiple matchers fails', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media One', false); + const matchers: Matcher[] = [ + { type: 'title', title: 'Test Media One' }, // Pass + { type: 'title', title: 'Test Media Two' }, // Fail + ]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return true if all multiple matchers pass', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media One', false); + const matchers: Matcher[] = [ + { type: 'title', title: 'Test Media One' }, + { + type: 'title', + regexp: `^(?Test Media One)$`, + title: 'Test Media One', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + + it('should ignore matchers of unknown types', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media', false); + + const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + }); +}); diff --git a/tests/card-controller/folders/ha/metadata-generator.test.ts b/tests/card-controller/folders/ha/metadata-generator.test.ts new file mode 100644 index 00000000..75c810ff --- /dev/null +++ b/tests/card-controller/folders/ha/metadata-generator.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { MetadataGenerator } from '../../../../src/card-controller/folders/ha/metadata-generator'; +import { Parser } from '../../../../src/config/schema/folders'; +import { createBrowseMedia, createRichBrowseMedia } from '../../../test-utils'; + +describe('MetadataGenerator', () => { + const browseMedia = createBrowseMedia({ + title: 'Test Media 2025-05-26 18:18', + }); + const expectedDate = new Date('2025-05-26T18:18:00.000Z'); + const formatlessDateParser: Parser = { + type: 'startdate', + }; + + describe('should dynamically import any-date-parser when needed', () => { + it('should not import any-date-parser if no parsers require it', async () => { + const generator = new MetadataGenerator(); + await generator.prepare([ + // Pretend no parsers require unknown date formats. + ]); + + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate, + ).toBeUndefined(); + }); + + it('should import any-date-parser if a parser requires it', async () => { + const generator = new MetadataGenerator(); + + await generator.prepare([formatlessDateParser]); + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate, + ).toEqual(expectedDate); + + // Re-preparing should nothing. + await generator.prepare([formatlessDateParser]); + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate, + ).toEqual(expectedDate); + }); + }); + + describe('should generate metadata', () => { + it('should not generate metadata without an parsers', async () => { + const generator = new MetadataGenerator(); + await generator.prepare([]); + + expect(generator.generate(browseMedia)).toBeNull(); + }); + + it('should ignore unknown parsers', async () => { + const goodParser: Parser = { + type: 'date', + }; + const badParser: Parser = { + type: 'UNKNOWN' as 'date', + }; + const generator = new MetadataGenerator(); + await generator.prepare([goodParser, badParser]); + + expect( + generator.generate(browseMedia, undefined, [goodParser, badParser]), + ).toEqual({ + startDate: expectedDate, + }); + }); + + describe('should generate date metadata', () => { + describe('should generate date metadata without a date format', () => { + it('should generate start date without a date format', async () => { + const generator = new MetadataGenerator(); + await generator.prepare([formatlessDateParser]); + + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser]) + ?.startDate, + ).toEqual(expectedDate); + }); + + it('should treat date as an alias for the startdate parser', async () => { + const parser: Parser = { + type: 'date', + }; + const generator = new MetadataGenerator(); + await generator.prepare([parser]); + + expect( + generator.generate(browseMedia, undefined, [parser])?.startDate, + ).toEqual(expectedDate); + }); + + it('should not parse when regexp fails to match', async () => { + const parser: Parser = { + type: 'date', + regexp: 'WILL_NOT_MATCH', + }; + const generator = new MetadataGenerator(); + await generator.prepare([parser]); + + expect( + generator.generate(browseMedia, undefined, [parser])?.startDate, + ).toBeUndefined(); + }); + + it('should fail to generate start date without a date format', async () => { + const badBrowseMedia = createBrowseMedia({ + title: 'Test Media NO DATE', + }); + const generator = new MetadataGenerator(); + await generator.prepare([formatlessDateParser]); + + expect( + generator.generate(badBrowseMedia, undefined, [formatlessDateParser]) + ?.startDate, + ).toBeUndefined(); + }); + + it('should incorporate parent metadata without a date format', async () => { + const parentBrowseMedia = createRichBrowseMedia({ + title: '2025-05-26', + _metadata: { + startDate: new Date('2025-05-26T00:00:00.000Z'), + }, + }); + const childBrowseMedia = createBrowseMedia({ + title: '22:42', + }); + const generator = new MetadataGenerator(); + await generator.prepare([formatlessDateParser]); + + expect( + generator.generate(childBrowseMedia, parentBrowseMedia, [ + formatlessDateParser, + ])?.startDate, + ).toEqual(new Date('2025-05-26T22:42:00.000Z')); + }); + }); + + describe('should generate date metadata with a date format', () => { + it('should generate start date with a date formater and a regexp', async () => { + const dateParser: Parser = { + type: 'startdate', + format: 'yyyy-MM-dd HH:mm', + regexp: 'Test Media (?.*)', + }; + const generator = new MetadataGenerator(); + await generator.prepare([dateParser]); + + expect( + generator.generate(browseMedia, undefined, [dateParser])?.startDate, + ).toEqual(expectedDate); + }); + + it('should fail to generate start date with a date formater without a regexp', async () => { + const dateParser: Parser = { + type: 'startdate', + format: 'yyyy-MM-dd HH:mm', + }; + const generator = new MetadataGenerator(); + await generator.prepare([dateParser]); + + expect( + generator.generate(browseMedia, undefined, [dateParser])?.startDate, + ).toBeUndefined(); + }); + + it('should incorporate parent metadata with a date format', async () => { + const parentBrowseMedia = createRichBrowseMedia({ + title: '2025-05-26', + _metadata: { + startDate: new Date('2025-05-26T00:00:00.000Z'), + }, + }); + const childBrowseMedia = createBrowseMedia({ + title: '22:42', + }); + const parser: Parser = { + type: 'startdate', + format: 'HH:mm', + }; + const generator = new MetadataGenerator(); + await generator.prepare([parser]); + + expect( + generator.generate(childBrowseMedia, parentBrowseMedia, [parser])?.startDate, + ).toEqual(new Date('2025-05-26T22:42:00.000Z')); + }); + }); + }); + }); +}); diff --git a/tests/components-lib/gallery/folder-gallery-controller.test.ts b/tests/components-lib/gallery/folder-gallery-controller.test.ts index 0e774e80..1441a8d3 100644 --- a/tests/components-lib/gallery/folder-gallery-controller.test.ts +++ b/tests/components-lib/gallery/folder-gallery-controller.test.ts @@ -161,7 +161,7 @@ describe('FolderGalleryController', () => { }), query: new FolderViewQuery({ folder, - path: [{ id: 'grandparent' }], + path: [{ ha: { id: 'grandparent' } }], }), }); @@ -182,7 +182,7 @@ describe('FolderGalleryController', () => { expect(newQuery).toBeInstanceOf(FolderViewQuery); expect(newQuery?.getQuery()).toEqual({ folder, - path: [{ id: 'grandparent' }, { id: folderItem.getID() }], + path: [{ ha: { id: 'grandparent' } }, { folder: folderItem }], }); }); @@ -214,7 +214,7 @@ describe('FolderGalleryController', () => { const view = createView({ query: new FolderViewQuery({ folder, - path: [{ id: 'id' }], + path: [{ ha: { id: 'id' } }], }), }); const viewManager = mock(); diff --git a/tests/components-lib/thumbnail/details-controller.test.ts b/tests/components-lib/thumbnail/details-controller.test.ts index 8eed8164..e2ddb055 100644 --- a/tests/components-lib/thumbnail/details-controller.test.ts +++ b/tests/components-lib/thumbnail/details-controller.test.ts @@ -118,10 +118,9 @@ describe('ThumbnailDetailsController', () => { ]); }); - it('should not have title with a what and a start time', () => { + it('should not have title with a start time', () => { const item = new TestViewMedia({ title: 'Test Event', - what: ['person', 'car'], startTime: new Date('2025-05-22T21:12:00Z'), }); diff --git a/tests/config/schema/folders.test.ts b/tests/config/schema/folders.test.ts index cd73e971..d9462979 100644 --- a/tests/config/schema/folders.test.ts +++ b/tests/config/schema/folders.test.ts @@ -60,7 +60,7 @@ describe('transformURLToMediaSourceRoot', () => { it.each(prefixes)('with prefix %s', (urlPrefix: string) => { const url = `${urlPrefix}media-browser/browser,does-not-start-with-media-source`; expect(() => transformPathURLToPathArray(url)).toThrowError( - /Could not parse valid media source URL/, + /Could not parse media source URL/, ); }); }); diff --git a/tests/ha/sort-browse-media-by-start-date.test.ts b/tests/ha/sort-browse-media-by-start-date.test.ts deleted file mode 100644 index 8c82cc8b..00000000 --- a/tests/ha/sort-browse-media-by-start-date.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { sortMediaByStartDate } from '../../src/ha/browse-media/sort-browse-media-by-start-date'; -import { BrowseMediaMetadata, RichBrowseMedia } from '../../src/ha/browse-media/types'; -import { createBrowseMedia, createRichBrowseMedia } from '../test-utils'; - -const createMetadata = ( - metadata: Partial, -): BrowseMediaMetadata => ({ - cameraID: 'camera.office', - startDate: new Date('2025-05-10T20:22:00Z'), - endDate: new Date('2025-05-10T20:22:10Z'), - ...metadata, -}); - -describe('sortMediaByStartDate', () => { - it('should return an empty array when given an empty array', () => { - const media: RichBrowseMedia[] = []; - expect(sortMediaByStartDate(media)).toEqual([]); - }); - - it('should sort media by startDate in descending order', () => { - const media: RichBrowseMedia[] = [ - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 2', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }), - }), - ]; - const expected: RichBrowseMedia[] = [ - createRichBrowseMedia({ - title: 'Media 2', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - ]; - expect(sortMediaByStartDate(media)).toEqual(expected); - }); - - it('should handle items with undefined startDate by placing them at the end', () => { - const media: RichBrowseMedia[] = [ - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - createBrowseMedia({ - title: 'Media 2', - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createBrowseMedia({ - title: 'Media 4', - }), - ]; - const expected: RichBrowseMedia[] = [ - createBrowseMedia({ - title: 'Media 2', - }), - createBrowseMedia({ - title: 'Media 4', - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - ]; - - expect(sortMediaByStartDate(media)).toEqual(expected); - }); -}); diff --git a/tests/utils/regexp-extract.test.ts b/tests/utils/regexp-extract.test.ts new file mode 100644 index 00000000..1071a5ad --- /dev/null +++ b/tests/utils/regexp-extract.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { regexpExtract } from '../../src/utils/regexp-extract'; + +describe('regexpExtract', () => { + it('returns null if no match is found', () => { + expect(regexpExtract(/foo/, 'bar')).toBeNull(); + }); + + it('returns the full match if no group is specified', () => { + expect(regexpExtract(/foo/, 'foo bar')).toBe('foo'); + }); + + it('returns the named group if groupName is specified', () => { + const pattern = /(?\w+)\s(?\w+)/; + expect(regexpExtract(pattern, 'hello world', { groupName: 'first' })).toBe('hello'); + expect(regexpExtract(pattern, 'hello world', { groupName: 'second' })).toBe('world'); + }); + + it('returns the numbered group if groupNumber is specified', () => { + expect(regexpExtract(/(\d+)-(\w+)/, '123-abc', { groupNumber: 1 })).toBe('123'); + expect(regexpExtract(/(\d+)-(\w+)/, '123-abc', { groupNumber: 2 })).toBe('abc'); + }); + + it('returns full match if groupName does not exist', () => { + expect(regexpExtract(/(?\w+)/, 'bar', { groupName: 'baz' })).toBe('bar'); + }); + + it('returns full match if groupNumber does not exist', () => { + expect(regexpExtract(/(\d+)/, '123', { groupNumber: 2 })).toBe('123'); + }); + + it('works with string pattern', () => { + expect(regexpExtract('(foo)', 'foo bar', { groupNumber: 1 })).toBe('foo'); + }); + + it('prefers groupName over groupNumber if both are provided', () => { + const pattern = /(?\w+)\s(\w+)/; + expect( + regexpExtract(pattern, 'hello world', { groupName: 'word', groupNumber: 2 }), + ).toBe('hello'); + }); + + it('returns null if match exists but group is undefined', () => { + expect(regexpExtract(/foo(bar)?/, 'foo', { groupNumber: 1 })).toBe('foo'); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index fa084e55..483319c8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,6 +36,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/media-layout.ts', 'utils/media.ts', 'utils/ptz.ts', + 'utils/regexp-extract.ts', 'utils/screenshot.ts', 'utils/scroll.ts', 'utils/substream.ts', diff --git a/yarn.lock b/yarn.lock index 7da752e8..ae75e320 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2500,6 +2500,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.30.1" "@typescript-eslint/parser": "npm:^8.30.1" "@vitest/coverage-istanbul": "npm:^1.6.0" + any-date-parser: "npm:^2.2.0" component-emitter: "npm:^1.3.1" compute-scroll-into-view: "npm:^3.1.1" conventional-changelog-conventionalcommits: "npm:^8.0.0" @@ -2697,6 +2698,13 @@ __metadata: languageName: node linkType: hard +"any-date-parser@npm:^2.2.0": + version: 2.2.0 + resolution: "any-date-parser@npm:2.2.0" + checksum: 10c0/e0d3cbadedc761c20fc351497a3fc8f467e2dbf9f3d90637c0ee966b7fc249beb2032d96cfaf9c91200a7c91cf2bff116e617a29afd0bcaf868bf35d595b4956 + languageName: node + linkType: hard + "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0"