feat: Add folder title matching and parsing (#2067)
This is a breaking change for users of the [experimental 'folder' functionality](https://card.camera/#/configuration/folders). Related: #1748
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<BrowseMediaMetadata>) =>
|
||||
media.can_expand &&
|
||||
isMediaWithinDates(media, matchOptions?.start, matchOptions?.end),
|
||||
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
||||
sortMediaByStartDate(media),
|
||||
},
|
||||
],
|
||||
{
|
||||
@@ -278,8 +275,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
!media.can_expand &&
|
||||
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
||||
sortMediaByStartDate(media),
|
||||
},
|
||||
],
|
||||
{
|
||||
@@ -357,8 +352,8 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
for (const dayDirectory of directories ?? []) {
|
||||
/* 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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<BrowseMediaMetadata>();
|
||||
|
||||
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<FolderPathComponent> {
|
||||
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id;
|
||||
|
||||
const path: HAFolderPathComponent[] = [
|
||||
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
|
||||
...(haFolderConfig?.url ?? []),
|
||||
...(haFolderConfig?.path ?? []),
|
||||
];
|
||||
|
||||
return path.map((component) => ({ ha: component })) as [
|
||||
FolderPathComponent,
|
||||
...FolderPathComponent[],
|
||||
];
|
||||
}
|
||||
|
||||
public async expandFolder(
|
||||
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<BrowseMediaMetadata> | 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<BrowseMediaMetadata>[],
|
||||
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
||||
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<BrowseMediaMetadata>(
|
||||
hass,
|
||||
generateStep([start]),
|
||||
{
|
||||
@@ -148,33 +188,4 @@ export class HAFoldersEngine implements FoldersEngine {
|
||||
folder: query.folder,
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultFolderPathComponents(
|
||||
haFolderConfig?: HAFolderConfig,
|
||||
): NonEmptyTuple<FolderPathComponent> {
|
||||
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id;
|
||||
|
||||
const defaultPath = [
|
||||
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
|
||||
...(haFolderConfig?.url ?? []),
|
||||
...(haFolderConfig?.path ?? []),
|
||||
];
|
||||
|
||||
return defaultPath.map((component) =>
|
||||
this._convertHAPathComponentToFolderPathComponent(component),
|
||||
) as [FolderPathComponent, ...FolderPathComponent[]];
|
||||
}
|
||||
|
||||
// Convert from the HA folder path component config schema to the general,
|
||||
// which pulls `path` to the top level.
|
||||
private _convertHAPathComponentToFolderPathComponent(
|
||||
component: HAFolderPathComponent,
|
||||
): FolderPathComponent {
|
||||
return {
|
||||
id: component.id,
|
||||
ha: {
|
||||
...component,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Matcher } from '../../../config/schema/folders';
|
||||
import { BrowseMedia } from '../../../ha/browse-media/types';
|
||||
import { regexpExtract } from '../../../utils/regexp-extract';
|
||||
import { REGEXP_GROUP_VALUE_KEY } from './types';
|
||||
|
||||
export class MediaMatcher {
|
||||
public match(media: BrowseMedia, matchers?: Matcher[], foldersOnly = false): boolean {
|
||||
if (foldersOnly && !media.can_expand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const matcher of matchers ?? []) {
|
||||
if (matcher.type === 'title') {
|
||||
if (!this._matchTitle(matcher, media.title)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _matchTitle(matcher: Matcher, src: string): boolean {
|
||||
const valueToMatch = matcher.regexp
|
||||
? regexpExtract(matcher.regexp, src, { groupName: REGEXP_GROUP_VALUE_KEY })
|
||||
: src;
|
||||
|
||||
if (!valueToMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (matcher.title) {
|
||||
return valueToMatch === matcher.title;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { parse } from 'date-fns';
|
||||
import { Parser } from '../../../config/schema/folders';
|
||||
import {
|
||||
BrowseMedia,
|
||||
BrowseMediaMetadata,
|
||||
RichBrowseMedia,
|
||||
} from '../../../ha/browse-media/types';
|
||||
|
||||
import parser from 'any-date-parser';
|
||||
import { isValidDate } from '../../../utils/basic';
|
||||
import { regexpExtract } from '../../../utils/regexp-extract';
|
||||
import { REGEXP_GROUP_VALUE_KEY } from './types';
|
||||
|
||||
export class MetadataGenerator {
|
||||
protected _anyDateParser: typeof parser | null = null;
|
||||
|
||||
public async prepare(parsers?: Parser[]): Promise<void> {
|
||||
if (this._anyDateParser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamically import the any-date-parser only if we have a parser that
|
||||
// requires it, in order to save on bundle size.
|
||||
if (
|
||||
parsers?.some(
|
||||
(parser) => ['date', 'startdate'].includes(parser.type) && !parser.format,
|
||||
)
|
||||
) {
|
||||
this._anyDateParser = (await import('any-date-parser')).default;
|
||||
}
|
||||
}
|
||||
|
||||
public generate(
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
parsers?: Parser[],
|
||||
): BrowseMediaMetadata | null {
|
||||
// Always propagate metadata from parent to children.
|
||||
const metadata: BrowseMediaMetadata = {
|
||||
...parent?._metadata,
|
||||
};
|
||||
|
||||
for (const parser of parsers ?? []) {
|
||||
const valueToParse = parser.regexp
|
||||
? regexpExtract(parser.regexp, media.title, {
|
||||
groupName: REGEXP_GROUP_VALUE_KEY,
|
||||
})
|
||||
: media.title;
|
||||
if (!valueToParse) {
|
||||
continue;
|
||||
}
|
||||
if (parser.type === 'startdate' || parser.type === 'date') {
|
||||
metadata.startDate =
|
||||
this._parseDate(parser, valueToParse, parent?._metadata?.startDate) ??
|
||||
undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(metadata).length > 0 ? metadata : null;
|
||||
}
|
||||
|
||||
private _parseDate(parser: Parser, src: string, base?: Date): Date | undefined {
|
||||
if (parser.format) {
|
||||
return this._parseFormattedDate(parser.format, src, base);
|
||||
}
|
||||
return this._parseUnknownDate(src, base);
|
||||
}
|
||||
|
||||
private _parseFormattedDate(
|
||||
format: string,
|
||||
src: string,
|
||||
base?: Date,
|
||||
): Date | undefined {
|
||||
const result = parse(src, format, base ?? new Date());
|
||||
return isValidDate(result) ? result : undefined;
|
||||
}
|
||||
|
||||
private _parseUnknownDate(src: string, base?: Date): Date | undefined {
|
||||
if (!this._anyDateParser) {
|
||||
return undefined;
|
||||
}
|
||||
const result = this._anyDateParser.attempt(src);
|
||||
if (!Object.keys(result).length) {
|
||||
return undefined;
|
||||
}
|
||||
return this._anyDateParser.fromObject({
|
||||
...(base && {
|
||||
year: base.getFullYear(),
|
||||
month: base.getMonth() + 1,
|
||||
day: base.getDate(),
|
||||
hour: base.getHours(),
|
||||
minute: base.getMinutes(),
|
||||
second: base.getSeconds(),
|
||||
millisecond: base.getMilliseconds(),
|
||||
}),
|
||||
...result,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const REGEXP_GROUP_VALUE_KEY = 'value';
|
||||
@@ -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<HAFolderPathComponent, 'id'>;
|
||||
folder?: ViewFolder;
|
||||
ha?: HAFolderPathComponent;
|
||||
};
|
||||
|
||||
export interface FolderQuery {
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<typeof parserSchema>;
|
||||
|
||||
const titleMatcherSchema = parserBaseSchema.extend({
|
||||
type: z.literal('title'),
|
||||
regexp: regexSchema.optional(),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
const matcherSchema = z.discriminatedUnion('type', [titleMatcherSchema]);
|
||||
export type Matcher = z.infer<typeof matcherSchema>;
|
||||
|
||||
const haFolderPathComponentSchema = z.object({
|
||||
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<typeof haFolderPathComponentSchema>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,9 +13,7 @@ export const getViewMediaFromBrowseMediaArray = (
|
||||
);
|
||||
};
|
||||
|
||||
export const getViewItemsFromBrowseMediaArray = <
|
||||
M extends BrowseMediaMetadata | undefined,
|
||||
>(
|
||||
export const getViewItemsFromBrowseMediaArray = <M extends BrowseMediaMetadata>(
|
||||
browseMedia: RichBrowseMedia<M>[],
|
||||
options?: ViewMediaSourceOptions,
|
||||
): ViewItem[] => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
export class BrowseMediaViewItemFactory {
|
||||
static create(
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata | undefined>,
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
options?: ViewMediaSourceOptions,
|
||||
): ViewItem | null {
|
||||
if (browseMedia.can_expand) {
|
||||
|
||||
@@ -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<BrowseMediaMetadata>;
|
||||
|
||||
constructor(folder: FolderConfig, browseMedia: RichBrowseMedia<BrowseMediaMetadata>) {
|
||||
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<BrowseMediaMetadata> {
|
||||
return this._browseMedia;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { orderBy } from 'lodash-es';
|
||||
import { BrowseMediaMetadata, RichBrowseMedia } from './types';
|
||||
|
||||
export const sortMediaByStartDate = (
|
||||
media: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||
): RichBrowseMedia<BrowseMediaMetadata>[] => {
|
||||
return orderBy(media, (media) => media._metadata?.startDate, 'desc');
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user