feat: add relative date search fields

This commit is contained in:
Christian Benincasa
2026-04-12 10:50:05 -04:00
parent fcb3ce8b09
commit 153e41f695
12 changed files with 546 additions and 23 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 ?? [],
@@ -1595,6 +1602,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

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

View File

@@ -236,6 +236,7 @@ export abstract class MediaSourceTvShowLibraryScanner<
const persistedShow: ShowT & HasMediaSourceAndLibraryId = {
...show,
uuid: upsertedShow.uuid,
createdAt: upsertedShow.createdAt,
mediaSourceId: mediaSource.uuid,
libraryId: library.uuid,
};
@@ -508,7 +509,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',
@@ -1003,6 +1120,9 @@ export function parsedSearchToRequest(
},
} 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 +1130,7 @@ export function parsedSearchToRequest(
name: originalField,
op: NumericOpToApiType[input.op],
type: 'date' as const,
value: converter(input.value),
value: converter(value),
},
} satisfies SearchFilterValueNode;
}
@@ -1220,6 +1340,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

@@ -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.optional(),
});
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;
/**
@@ -155,6 +156,7 @@ export type TerminalProgramInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -294,6 +296,7 @@ export type TerminalProgramInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -435,6 +438,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 +492,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 +552,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 +603,7 @@ export type SeasonInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@@ -738,6 +745,7 @@ export type EpisodeInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@@ -880,6 +888,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 +919,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 +979,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 +1023,7 @@ export type MusicAlbumInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -1162,6 +1174,7 @@ export type MusicTrackInput = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -1362,11 +1375,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 +1400,7 @@ export type TerminalProgram = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -1525,6 +1544,7 @@ export type TerminalProgram = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -1664,6 +1684,7 @@ export type TerminalProgram = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -1805,6 +1826,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 +1880,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 +1940,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 +1991,7 @@ export type Season = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@@ -2108,6 +2133,7 @@ export type Episode = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
releaseDate: number | null;
@@ -2250,6 +2276,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 +2307,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 +2367,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 +2411,7 @@ export type MusicAlbum = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -2532,6 +2562,7 @@ export type MusicTrack = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -2732,11 +2763,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 +6710,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 +10243,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -10350,6 +10387,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**
@@ -10489,6 +10527,7 @@ export type GetApiPlexByMediaSourceIdSearchResponses = {
title: string;
sortTitle: string;
tags: Array<string>;
createdAt?: number | null;
originalTitle: string | null;
year: number | null;
/**

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: {
'!=': '!=',