feat: add relative date search fields (#1796)
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Christian Benincasa 2026-04-20 15:34:30 -04:00 committed by GitHub
parent 6ef231c48d
commit 7d2593403a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 615 additions and 26 deletions

File diff suppressed because one or more lines are too long

View file

@ -132,6 +132,7 @@ const ProgramsIndex: TunarrSearchIndex<ProgramSearchDocument> = {
'rating',
'originalReleaseDate',
'originalReleaseYear',
'addedAt',
'externalIdsMerged',
'grandparent.id',
'grandparent.type',
@ -172,6 +173,7 @@ const ProgramsIndex: TunarrSearchIndex<ProgramSearchDocument> = {
'duration',
'originalReleaseDate',
'originalReleaseYear',
'addedAt',
'index',
],
caseSensitiveFilters: [
@ -277,6 +279,7 @@ type BaseProgramSearchDocument = {
studio?: Studio[];
tags: string[];
state: ProgramState;
addedAt: Nullable<number>;
};
export type TerminalProgramSearchDocument<
@ -830,6 +833,7 @@ export class MeilisearchService implements ISearchService {
tags: show.tags,
studio: show.studios,
state: 'ok',
addedAt: show.createdAt ?? null,
};
await this.client()
@ -881,6 +885,7 @@ export class MeilisearchService implements ISearchService {
),
tags: season.tags,
state: 'ok',
addedAt: season.createdAt ?? null,
parent: {
id: encodeCaseSensitiveId(season.show.uuid),
externalIds: showEids ?? [],
@ -1005,6 +1010,7 @@ export class MeilisearchService implements ISearchService {
),
tags: artist.tags,
state: 'ok',
addedAt: artist.createdAt ?? null,
};
await this.client()
@ -1054,6 +1060,7 @@ export class MeilisearchService implements ISearchService {
),
tags: album.tags,
state: 'ok',
addedAt: album.createdAt ?? null,
parent: {
id: encodeCaseSensitiveId(album.artist.uuid),
externalIds: artistEids ?? [],
@ -1447,13 +1454,42 @@ export class MeilisearchService implements ISearchService {
},
)
.with(
{ type: P.union('date', 'numeric'), value: [P.number, P.number] },
P.union(
{ type: 'numeric', value: [P.number, P.number] },
{
type: 'date',
value: [P.number, P.number],
},
),
({ value }) => {
return `${value[0]} TO ${value[1]}`;
},
)
.with(
{ type: P.union('date', 'numeric'), value: P.number },
{ type: 'numeric', value: P.number },
({ value, op }) => `${op.toUpperCase()} ${value}`,
)
.with(
{
type: 'date',
relativeDate: { op: 'inthelast' },
value: P.number,
},
({ value }) => `>= ${value}`,
)
.with(
{
type: 'date',
relativeDate: { op: 'notinthelast' },
value: P.number,
},
({ value }) => `< ${value}`,
)
.with(
{
type: 'date',
value: P.number,
},
({ value, op }) => `${op.toUpperCase()} ${value}`,
)
.otherwise(() => null);
@ -1595,6 +1631,7 @@ export class MeilisearchService implements ISearchService {
writer: program.writers ?? [],
studio: program.studios ?? [],
tags: program.tags,
addedAt: program.createdAt ?? null,
mediaSourceId: encodeCaseSensitiveId(program.mediaSourceId),
libraryId: encodeCaseSensitiveId(program.libraryId),
videoWidth: width,

View file

@ -158,6 +158,7 @@ export abstract class MediaSourceMovieLibraryScanner<
{
...fullMovie,
uuid: dbMovie.uuid,
createdAt: dbMovie.createdAt,
mediaSourceId: mediaSource.uuid,
libraryId: library.uuid,
},

View file

@ -238,6 +238,7 @@ export abstract class MediaSourceTvShowLibraryScanner<
const persistedShow: ShowT & HasMediaSourceAndLibraryId = {
...show,
uuid: upsertedShow.uuid,
createdAt: upsertedShow.createdAt,
mediaSourceId: mediaSource.uuid,
libraryId: library.uuid,
};
@ -518,7 +519,11 @@ export abstract class MediaSourceTvShowLibraryScanner<
this.logger.trace('Upserted episode ID %s', upsertResult!.uuid);
await this.searchService.indexEpisodes([
{ ...episodeWithJoins, uuid: upsertResult!.uuid },
{
...episodeWithJoins,
uuid: upsertResult!.uuid,
createdAt: upsertResult!.createdAt,
},
]);
} catch (e) {
this.logger.warn(

View file

@ -649,4 +649,193 @@ describe('searchFilterToString', () => {
const request = parsedSearchToRequest(query);
expect(searchFilterToString(request)).toEqual(input);
});
// Relative date query tests
test('parse release_date inthelast', () => {
const input = 'release_date inthelast 2 weeks';
const query = parseAndCheckExpression(input);
expect(query).toMatchObject({
type: 'single_date_query',
field: 'release_date',
op: 'inthelast',
value: { amount: 2, unit: 'week' },
} satisfies SearchClause);
});
test('parse release_date notinthelast', () => {
const input = 'release_date notinthelast 3 months';
const query = parseAndCheckExpression(input);
expect(query).toMatchObject({
type: 'single_date_query',
field: 'release_date',
op: 'notinthelast',
value: { amount: 3, unit: 'month' },
} satisfies SearchClause);
});
test('parse added_date inthelast', () => {
const input = 'added_date inthelast 1 week';
const query = parseAndCheckExpression(input);
expect(query).toMatchObject({
type: 'single_date_query',
field: 'added_date',
op: 'inthelast',
value: { amount: 1, unit: 'week' },
} satisfies SearchClause);
});
test('parse case-insensitive relative date', () => {
const input = 'release_date INTHELAST 1 year';
const query = parseAndCheckExpression(input);
expect(query).toMatchObject({
type: 'single_date_query',
field: 'release_date',
op: 'inthelast',
value: { amount: 1, unit: 'year' },
} satisfies SearchClause);
});
test('parse singular unit', () => {
const input = 'release_date inthelast 1 day';
const query = parseAndCheckExpression(input);
expect(query).toMatchObject({
type: 'single_date_query',
field: 'release_date',
op: 'inthelast',
value: { amount: 1, unit: 'day' },
} satisfies SearchClause);
});
test('parse plural unit', () => {
const input = 'release_date inthelast 14 days';
const query = parseAndCheckExpression(input);
expect(query).toMatchObject({
type: 'single_date_query',
field: 'release_date',
op: 'inthelast',
value: { amount: 14, unit: 'day' },
} satisfies SearchClause);
});
test('inthelast resolves to >= with epoch ms', () => {
const clause = {
type: 'single_date_query',
field: 'release_date',
op: 'inthelast',
value: { amount: 2, unit: 'week' },
} satisfies SearchClause;
const before = +dayjs().subtract(2, 'week');
const request = parsedSearchToRequest(clause);
const after = +dayjs().subtract(2, 'week');
expect(request).toMatchObject({
type: 'value',
fieldSpec: {
key: 'originalReleaseDate',
name: 'release_date',
op: '>=',
type: 'date',
relativeDate: {
op: 'inthelast',
amount: 2,
unit: 'week',
},
},
});
// The resolved value should be approximately 2 weeks ago
const value = (request as { fieldSpec: { value: number } }).fieldSpec.value;
expect(value).toBeGreaterThanOrEqual(before);
expect(value).toBeLessThanOrEqual(after);
});
test('notinthelast resolves to < with epoch ms', () => {
const clause = {
type: 'single_date_query',
field: 'release_date',
op: 'notinthelast',
value: { amount: 3, unit: 'month' },
} satisfies SearchClause;
const request = parsedSearchToRequest(clause);
expect(request).toMatchObject({
type: 'value',
fieldSpec: {
key: 'originalReleaseDate',
name: 'release_date',
op: '<',
type: 'date',
relativeDate: {
op: 'notinthelast',
amount: 3,
unit: 'month',
},
},
});
});
test('added_date maps to addedAt index field', () => {
const clause = {
type: 'single_date_query',
field: 'added_date',
op: 'inthelast',
value: { amount: 1, unit: 'week' },
} satisfies SearchClause;
const request = parsedSearchToRequest(clause);
expect(request).toMatchObject({
type: 'value',
fieldSpec: {
key: 'addedAt',
name: 'added_date',
op: '>=',
type: 'date',
},
});
});
test('round-trip relative date through parse and stringify', () => {
const input = 'release_date inthelast 2 weeks';
const query = parseAndCheckExpression(input);
const request = parsedSearchToRequest(query);
expect(searchFilterToString(request)).toEqual(input);
});
test('round-trip notinthelast through parse and stringify', () => {
const input = 'release_date notinthelast 1 month';
const query = parseAndCheckExpression(input);
const request = parsedSearchToRequest(query);
expect(searchFilterToString(request)).toEqual(input);
});
test('round-trip singular unit', () => {
const input = 'release_date inthelast 1 day';
const query = parseAndCheckExpression(input);
const request = parsedSearchToRequest(query);
expect(searchFilterToString(request)).toEqual(input);
});
test('compound query with relative date', () => {
const input = 'release_date inthelast 2 weeks AND genre = "comedy"';
const query = parseAndCheckExpression(input);
expect(query).toMatchObject({
type: 'binary_clause',
op: 'and',
lhs: {
type: 'single_date_query',
field: 'release_date',
op: 'inthelast',
value: { amount: 2, unit: 'week' },
},
rhs: {
type: 'single_query',
field: 'genre',
op: '=',
value: 'comedy',
},
} satisfies SearchClause);
});
});

View file

@ -77,7 +77,7 @@ const StringField = createToken({
longer_alt: Identifier,
});
const DateFields = ['release_date'] as const;
const DateFields = ['release_date', 'added_date'] as const;
const DateField = createToken({
name: 'DateField',
@ -184,6 +184,24 @@ const GreaterThanOrEqualOperator = createToken({
});
const GreaterThanOperator = createToken({ name: 'GTOperator', pattern: />/ });
const NotInTheLastOperator = createToken({
name: 'NotInTheLastOperator',
pattern: /notinthelast/i,
longer_alt: Identifier,
});
const InTheLastOperator = createToken({
name: 'InTheLastOperator',
pattern: /inthelast/i,
longer_alt: Identifier,
});
const RelativeDateUnit = createToken({
name: 'RelativeDateUnit',
pattern: /days?|weeks?|months?|years?/i,
longer_alt: Identifier,
});
const NotOperator = createToken({ name: 'NotOperator', pattern: /not/i });
const InOperator = createToken({ name: 'InOperator', pattern: /in/i });
@ -209,6 +227,9 @@ const allTokens = [
NeqOperator,
LessThanOperator,
GreaterThanOperator,
// Relative date operators must precede Not/In to avoid partial matches
NotInTheLastOperator,
InTheLastOperator,
NotOperator,
InOperator,
BetweenOperator,
@ -222,6 +243,8 @@ const allTokens = [
StringField,
DateField,
NumericField,
// Relative date units must precede Identifier
RelativeDateUnit,
// Catch all
Identifier,
];
@ -247,7 +270,16 @@ const StringOps = [
type StringOps = TupleToUnion<typeof StringOps>;
const NumericOps = ['=', '!=', '<', '<=', '>', '>=', 'between'] as const;
type NumericOps = TupleToUnion<typeof NumericOps>;
const DateOps = ['=', '<', '<=', '>', '>=', 'between'] as const;
const DateOps = [
'=',
'<',
'<=',
'>',
'>=',
'between',
'inthelast',
'notinthelast',
] as const;
type DateOps = TupleToUnion<typeof DateOps>;
const StringOpToApiType = {
@ -301,11 +333,16 @@ export type SingleNumericQuery =
includeHigher: boolean;
};
export type RelativeDateValue = {
amount: number;
unit: 'day' | 'week' | 'month' | 'year';
};
export type SingleDateSearchQuery =
| {
type: 'single_date_query';
field: string;
op: StrictExclude<DateOps, 'between'>;
op: StrictExclude<DateOps, 'between' | 'inthelast' | 'notinthelast'>;
value: string;
}
| {
@ -315,6 +352,12 @@ export type SingleDateSearchQuery =
value: [string, string];
includeLow: boolean;
includeHigher: boolean;
}
| {
type: 'single_date_query';
field: string;
op: 'inthelast' | 'notinthelast';
value: RelativeDateValue;
};
export type SingleSearch =
@ -343,6 +386,7 @@ export const virtualFieldToIndexField: Record<string, string> = {
studio: 'studio.name',
year: 'originalReleaseYear',
release_date: 'originalReleaseDate',
added_date: 'addedAt',
release_year: 'originalReleaseYear',
// these get mapped to the duration field and their
// values get converted to the appropriate units
@ -401,6 +445,7 @@ const numericFieldDenormalizersByField = {
const dateFieldNormalizersByField = {
release_date: normalizeReleaseDate,
added_date: normalizeReleaseDate,
} satisfies Record<string, Converter<string, number>>;
export class SearchParser extends EmbeddedActionsParser {
@ -622,7 +667,49 @@ export class SearchParser extends EmbeddedActionsParser {
return this.OR<StrictOmit<SingleDateSearchQuery, 'field'>>([
{
ALT: () => {
const op = this.OR2<StrictExclude<DateOps, 'between'>>([
const op = this.OR2<'inthelast' | 'notinthelast'>([
{
ALT: () => {
this.CONSUME(InTheLastOperator);
return 'inthelast' as const;
},
},
{
ALT: () => {
this.CONSUME(NotInTheLastOperator);
return 'notinthelast' as const;
},
},
]);
const amount = parseInt(this.CONSUME2(Integer).image);
// 'year' is also a NumericField token, so we must accept both.
// During Chevrotain's grammar recording phase OR returns undefined,
// so we guard the toLowerCase call.
const unitImage = this.OR6<string>([
{
ALT: () => this.CONSUME(RelativeDateUnit).image,
},
{
ALT: () => this.CONSUME(NumericField).image,
},
]);
const unitRaw =
typeof unitImage === 'string' ? unitImage.toLowerCase() : '';
const unit = (
unitRaw.endsWith('s') ? unitRaw.slice(0, -1) : unitRaw
) as RelativeDateValue['unit'];
return {
type: 'single_date_query',
op,
value: { amount, unit },
} satisfies StrictOmit<SingleDateSearchQuery, 'field'>;
},
},
{
ALT: () => {
const op = this.OR3<
StrictExclude<DateOps, 'between' | 'inthelast' | 'notinthelast'>
>([
{
ALT: () => {
const tok = this.CONSUME(EqOperator);
@ -658,7 +745,7 @@ export class SearchParser extends EmbeddedActionsParser {
).image.toLowerCase() as 'between';
let inclLow = false,
inclHi = false;
this.OR3([
this.OR4([
{
ALT: () => this.CONSUME2(OpenParenGroup),
},
@ -673,7 +760,7 @@ export class SearchParser extends EmbeddedActionsParser {
values.push(this.SUBRULE2(this.searchValue));
this.OPTION(() => this.CONSUME2(Comma));
values.push(this.SUBRULE3(this.searchValue));
this.OR4([
this.OR5([
{
ALT: () => this.CONSUME3(CloseParenGroup),
},
@ -736,6 +823,14 @@ export class SearchParser extends EmbeddedActionsParser {
private singleDateSearch = this.RULE('singleDateSearch', () => {
const field = this.CONSUME(DateField, { LABEL: 'field' }).image;
const opRet = this.SUBRULE(this.dateOperatorAndValue, { LABEL: 'op' });
if (opRet.op === 'inthelast' || opRet.op === 'notinthelast') {
return {
type: 'single_date_query',
field,
op: opRet.op,
value: opRet.value,
} satisfies SingleDateSearchQuery;
}
if (opRet.op === 'between') {
return {
type: 'single_date_query',
@ -751,7 +846,7 @@ export class SearchParser extends EmbeddedActionsParser {
type: 'single_date_query',
field,
op: opRet.op,
value: opRet.value,
value: opRet.value as string,
} satisfies SingleDateSearchQuery;
});
@ -991,6 +1086,28 @@ export function parsedSearchToRequest(
]
: (input: string) => parseInt(input);
if (input.op === 'inthelast' || input.op === 'notinthelast') {
const resolved = +dayjs().subtract(
input.value.amount,
input.value.unit,
);
return {
type: 'value',
fieldSpec: {
key,
name: originalField,
op: input.op === 'inthelast' ? '>=' : '<',
type: 'date' as const,
value: resolved,
relativeDate: {
op: input.op,
amount: input.value.amount,
unit: input.value.unit,
},
},
} satisfies SearchFilterValueNode;
}
if (input.op === 'between') {
return {
type: 'value',
@ -1000,9 +1117,13 @@ export function parsedSearchToRequest(
op: NumericOpToApiType[input.op],
type: 'date' as const,
value: [converter(input.value[0]), converter(input.value[1])],
relativeDate: undefined,
},
} satisfies SearchFilterValueNode;
} else {
// After inthelast/notinthelast and between are handled above,
// value is always a string for comparison operators
const value = input.value as string;
return {
type: 'value',
fieldSpec: {
@ -1010,7 +1131,8 @@ export function parsedSearchToRequest(
name: originalField,
op: NumericOpToApiType[input.op],
type: 'date' as const,
value: converter(input.value),
value: converter(value),
relativeDate: undefined,
},
} satisfies SearchFilterValueNode;
}
@ -1220,6 +1342,14 @@ export function searchFilterToString(input: SearchFilter): string {
emptyStringToNull(input.fieldSpec.name) ??
head(indexFieldToVirtualField[input.fieldSpec.key]) ??
input.fieldSpec.key;
// Relative date expressions round-trip as their original syntax
if (input.fieldSpec.type === 'date' && input.fieldSpec.relativeDate) {
const rd = input.fieldSpec.relativeDate;
const unitStr = rd.amount === 1 ? rd.unit : rd.unit + 's';
return `${key} ${rd.op} ${rd.amount} ${unitStr}`;
}
const op =
indexOperatorToSyntax[input.fieldSpec.op] ?? input.fieldSpec.op;

View file

@ -1,10 +1,7 @@
import { z } from 'zod/v4';
import type { MediaSourceLibrary } from '../MediaSourceSettings.js';
import {
SearchField,
SearchFilterQuerySchema,
SearchRequest,
} from '../schemas/SearchRequest.js';
import type { SearchField, SearchRequest } from '../schemas/SearchRequest.js';
import { SearchFilterQuerySchema } from '../schemas/SearchRequest.js';
// A PlexSearch but with a reference to the
// library it is for.

View file

@ -16,6 +16,14 @@ const NumericOperators = ['=', '!=', '<', '>', '<=', '>=', 'to'] as const;
export type NumericOperators = TupleToUnion<typeof NumericOperators>;
const DateOperators = [
...NumericOperators,
'inthelast',
'notinthelast',
] as const;
export type DateOperators = TupleToUnion<typeof DateOperators>;
const BaseSearchFieldSchema = z.object({
key: z.string().describe('The actual field path in the search index'),
name: z
@ -52,9 +60,28 @@ const NumericSearchFieldSchema = z.object({
export type NumericSearchField = z.infer<typeof NumericSearchFieldSchema>;
export const RelativeDateUnits = ['day', 'week', 'month', 'year'] as const;
export type RelativeDateUnit = TupleToUnion<typeof RelativeDateUnits>;
const RelativeDateOps = ['inthelast', 'notinthelast'] as const;
export type RelativeDateOp = TupleToUnion<typeof RelativeDateOps>;
export const RelativeDateExprSchema = z.object({
op: z.enum(RelativeDateOps),
amount: z.number().int().positive(),
unit: z.enum(RelativeDateUnits),
});
export type RelativeDateExpr = z.infer<typeof RelativeDateExprSchema>;
const DateSearchFieldSchema = z.object({
...NumericSearchFieldSchema.shape,
...BaseSearchFieldSchema.shape,
type: z.literal('date'),
op: z.enum(DateOperators),
value: z.number().or(z.tuple([z.number(), z.number()])),
relativeDate: RelativeDateExprSchema.exactOptional(),
});
export type DateSearchField = z.infer<typeof DateSearchFieldSchema>;
@ -73,7 +100,7 @@ export type SearchFieldType = SearchField['type'];
export const OperatorsByType = {
string: StringOperators,
numeric: NumericOperators,
date: NumericOperators,
date: DateOperators,
faceted_string: StringOperators,
} satisfies Record<SearchField['type'], ReadonlyArray<string>>;
@ -127,6 +154,7 @@ export const SearchSortFields = [
'duration',
'originalReleaseDate',
'originalReleaseYear',
'addedAt',
'index',
] as const;

View file

@ -129,6 +129,7 @@ const BaseItem = z.object({
title: z.string(),
sortTitle: z.string(),
tags: z.array(z.string()),
createdAt: z.number().nullable().optional(),
// ...HasMediaSourceAndLibraryId.shape,
});

View file

@ -1,6 +1,15 @@
import { Stack } from '@mui/material';
import {
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
} from '@mui/material';
import type { PickerValidDate } from '@mui/x-date-pickers';
import { DatePicker } from '@mui/x-date-pickers';
import type { DateSearchField, RelativeDateUnit } from '@tunarr/types/schemas';
import { RelativeDateUnits } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { isNumber } from 'lodash-es';
import { useCallback } from 'react';
@ -14,9 +23,20 @@ type Props = {
formKey: FieldKey<FieldPrefix, 'fieldSpec'>;
};
const UnitLabels: Record<RelativeDateUnit, string> = {
day: 'Days',
week: 'Weeks',
month: 'Months',
year: 'Years',
};
function isRelativeDateOp(op: string): op is 'inthelast' | 'notinthelast' {
return op === 'inthelast' || op === 'notinthelast';
}
export function DateSearchValueNode({ formKey }: Props) {
const { control, watch } = useFormContext<SearchForm>();
const currentSpec = watch(formKey);
const { control, watch, setValue } = useFormContext<SearchForm>();
const currentSpec = watch(formKey) as DateSearchField;
const handleDateValueChange = useCallback(
(
@ -30,6 +50,74 @@ export function DateSearchValueNode({ formKey }: Props) {
[],
);
const handleRelativeAmountChange = useCallback(
(amount: number) => {
const unit = currentSpec.relativeDate?.unit ?? 'week';
const resolved = +dayjs().subtract(amount, unit);
setValue(`${formKey}.value`, resolved);
setValue(`${formKey}.relativeDate`, {
op: currentSpec.op as 'inthelast' | 'notinthelast',
amount,
unit,
});
},
[currentSpec.relativeDate?.unit, currentSpec.op, formKey, setValue],
);
const handleRelativeUnitChange = useCallback(
(unit: RelativeDateUnit) => {
const amount = currentSpec.relativeDate?.amount ?? 1;
const resolved = +dayjs().subtract(amount, unit);
setValue(`${formKey}.value`, resolved);
setValue(`${formKey}.relativeDate`, {
op: currentSpec.op as 'inthelast' | 'notinthelast',
amount,
unit,
});
},
[currentSpec.relativeDate?.amount, currentSpec.op, formKey, setValue],
);
if (isRelativeDateOp(currentSpec.op)) {
const amount = currentSpec.relativeDate?.amount ?? 1;
const unit = currentSpec.relativeDate?.unit ?? 'week';
return (
<Stack direction="row" gap={1}>
<TextField
type="number"
size="small"
label="Amount"
value={amount}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val > 0) {
handleRelativeAmountChange(val);
}
}}
slotProps={{ htmlInput: { min: 1 } }}
sx={{ width: 100 }}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Unit</InputLabel>
<Select
value={unit}
label="Unit"
onChange={(e) =>
handleRelativeUnitChange(e.target.value as RelativeDateUnit)
}
>
{RelativeDateUnits.map((u) => (
<MenuItem key={u} value={u}>
{UnitLabels[u]}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
);
}
if (isNumber(currentSpec.value)) {
return (
<Controller

View file

@ -123,7 +123,35 @@ export function SearchValueNode(props: ValueNodeProps) {
const handleOpChange = useCallback(
(newOp: string) => {
if (
const isRelativeOp = newOp === 'inthelast' || newOp === 'notinthelast';
const wasRelativeOp =
selfValue.fieldSpec.type === 'date' &&
(selfValue.fieldSpec.op === 'inthelast' ||
selfValue.fieldSpec.op === 'notinthelast');
if (isRelativeOp && selfValue.fieldSpec.type === 'date') {
// Switching to a relative date operator: set default relative metadata
const relativeDate = {
op: newOp,
amount: 1,
unit: 'week',
} as const;
const resolved = +dayjs().subtract(1, 'week');
setValue(getFieldName('fieldSpec.value'), resolved);
setValue(getFieldName('fieldSpec.relativeDate'), relativeDate);
} else if (wasRelativeOp && !isRelativeOp) {
// Switching from relative to absolute: clear relativeDate metadata
setValue(getFieldName('fieldSpec.relativeDate'), undefined);
if (newOp === 'to') {
const now = +dayjs();
setValue(getFieldName('fieldSpec.value'), [now, now] as [
number,
number,
]);
} else if (!isNumber(selfValue.fieldSpec.value)) {
setValue(getFieldName('fieldSpec.value'), +dayjs());
}
} else if (
selfValue.fieldSpec.type === 'numeric' ||
selfValue.fieldSpec.type === 'date'
) {
@ -145,7 +173,9 @@ export function SearchValueNode(props: ValueNodeProps) {
);
},
[
dayjs,
getFieldName,
selfValue.fieldSpec.op,
selfValue.fieldSpec.type,
selfValue.fieldSpec.value,
setValue,

View file

@ -12,6 +12,7 @@ export type TerminalProgramInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -111,6 +112,7 @@ export type TerminalProgramInput = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -141,6 +143,7 @@ export type TerminalProgramInput = {
chapterType?: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
} | EpisodeInput | MusicTrackInput | {
@ -155,6 +158,7 @@ export type TerminalProgramInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -250,6 +254,7 @@ export type TerminalProgramInput = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -280,6 +285,7 @@ export type TerminalProgramInput = {
chapterType?: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
} | {
@ -294,6 +300,7 @@ export type TerminalProgramInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -389,6 +396,7 @@ export type TerminalProgramInput = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -419,6 +427,7 @@ export type TerminalProgramInput = {
chapterType?: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
};
@ -435,6 +444,7 @@ export type ShowInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -488,6 +498,7 @@ export type ShowInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -547,6 +558,7 @@ export type SeasonInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -597,6 +609,7 @@ export type SeasonInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@ -691,6 +704,7 @@ export type SeasonInput = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -721,6 +735,7 @@ export type SeasonInput = {
chapterType?: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
}>;
@ -738,6 +753,7 @@ export type EpisodeInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@ -834,6 +850,7 @@ export type EpisodeInput = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -864,6 +881,7 @@ export type EpisodeInput = {
chapterType?: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
};
@ -880,6 +898,7 @@ export type MusicArtistInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -910,6 +929,7 @@ export type MusicArtistInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -969,6 +989,7 @@ export type MusicAlbumInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -1012,6 +1033,7 @@ export type MusicAlbumInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -1108,6 +1130,7 @@ export type MusicAlbumInput = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -1138,6 +1161,7 @@ export type MusicAlbumInput = {
chapterType?: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
}>;
@ -1162,6 +1186,7 @@ export type MusicTrackInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -1260,6 +1285,7 @@ export type MusicTrackInput = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -1290,6 +1316,7 @@ export type MusicTrackInput = {
chapterType?: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
};
@ -1362,11 +1389,16 @@ export type SearchFilterInput = {
*/
name?: string;
type: 'date';
op: '=' | '!=' | '<' | '>' | '<=' | '>=' | 'to';
op: '=' | '!=' | '<' | '>' | '<=' | '>=' | 'to' | 'inthelast' | 'notinthelast';
value: number | [
number,
number
];
relativeDate?: {
op: 'inthelast' | 'notinthelast';
amount: number;
unit: 'day' | 'week' | 'month' | 'year';
};
};
};
@ -1382,6 +1414,7 @@ export type TerminalProgram = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -1481,6 +1514,7 @@ export type TerminalProgram = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -1511,6 +1545,7 @@ export type TerminalProgram = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
} | Episode | MusicTrack | {
@ -1525,6 +1560,7 @@ export type TerminalProgram = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -1620,6 +1656,7 @@ export type TerminalProgram = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -1650,6 +1687,7 @@ export type TerminalProgram = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
} | {
@ -1664,6 +1702,7 @@ export type TerminalProgram = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -1759,6 +1798,7 @@ export type TerminalProgram = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -1789,6 +1829,7 @@ export type TerminalProgram = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
};
@ -1805,6 +1846,7 @@ export type Show = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -1858,6 +1900,7 @@ export type Show = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -1917,6 +1960,7 @@ export type Season = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -1967,6 +2011,7 @@ export type Season = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@ -2061,6 +2106,7 @@ export type Season = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -2091,6 +2137,7 @@ export type Season = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
}>;
@ -2108,6 +2155,7 @@ export type Episode = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@ -2204,6 +2252,7 @@ export type Episode = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -2234,6 +2283,7 @@ export type Episode = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
};
@ -2250,6 +2300,7 @@ export type MusicArtist = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -2280,6 +2331,7 @@ export type MusicArtist = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -2339,6 +2391,7 @@ export type MusicAlbum = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
summary: string | null;
plot: string | null;
tagline: string | null;
@ -2382,6 +2435,7 @@ export type MusicAlbum = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -2478,6 +2532,7 @@ export type MusicAlbum = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -2508,6 +2563,7 @@ export type MusicAlbum = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
}>;
@ -2532,6 +2588,7 @@ export type MusicTrack = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -2630,6 +2687,7 @@ export type MusicTrack = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -2660,6 +2718,7 @@ export type MusicTrack = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
};
@ -2732,11 +2791,16 @@ export type SearchFilter = {
*/
name?: string;
type: 'date';
op: '=' | '!=' | '<' | '>' | '<=' | '>=' | 'to';
op: '=' | '!=' | '<' | '>' | '<=' | '>=' | 'to' | 'inthelast' | 'notinthelast';
value: number | [
number,
number
];
relativeDate?: {
op: 'inthelast' | 'notinthelast';
amount: number;
unit: 'day' | 'week' | 'month' | 'year';
};
};
};
@ -6674,7 +6738,7 @@ export type PostApiProgramsSearchData = {
restrictSearchTo?: Array<string>;
filter?: SearchFilterInput | null;
sort?: Array<{
field: 'title' | 'sortTitle' | 'duration' | 'originalReleaseDate' | 'originalReleaseYear' | 'index';
field: 'title' | 'sortTitle' | 'duration' | 'originalReleaseDate' | 'originalReleaseYear' | 'addedAt' | 'index';
direction: 'asc' | 'desc';
}> | null;
};
@ -10207,6 +10271,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -10306,6 +10371,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -10336,6 +10402,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
} | Episode | Season | Show | MusicTrack | MusicAlbum | MusicArtist | {
@ -10350,6 +10417,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -10445,6 +10513,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -10475,6 +10544,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
} | {
@ -10489,6 +10559,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@ -10584,6 +10655,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
colorPrimaries?: string | null;
channels?: number | null;
sdh?: boolean | null;
externalKey?: string | null;
languageCodeISO6392?: string | null;
selected?: boolean | null;
default?: boolean | null;
@ -10614,6 +10686,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
chapterType: 'chapter' | 'intro' | 'outro';
}> | null;
scanKind?: ('unknown' | 'progressive' | 'interlaced') | null;
externalKey?: string | null;
};
duration: number;
}>;

View file

@ -155,6 +155,14 @@ export const SearchFieldSpecs: NonEmptyArray<
uiVisible: true,
visibleForLibraryTypes: 'all',
},
{
key: 'addedAt',
name: 'added_date',
type: 'date' as const,
displayName: 'Date Added',
uiVisible: true,
visibleForLibraryTypes: 'all',
},
{
key: 'originalReleaseYear',
name: 'year',
@ -296,6 +304,8 @@ const OperatorLabelByFieldType = {
'>': 'after',
'>=': 'on or after',
to: 'between',
inthelast: 'in the last',
notinthelast: 'not in the last',
},
numeric: {
'!=': '!=',