mirror of
https://github.com/chrisbenincasa/tunarr
synced 2026-04-21 13:37:15 +00:00
feat: add relative date search fields (#1796)
Some checks are pending
Build / build (push) Waiting to run
Some checks are pending
Build / build (push) Waiting to run
This commit is contained in:
parent
6ef231c48d
commit
7d2593403a
13 changed files with 615 additions and 26 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export abstract class MediaSourceMovieLibraryScanner<
|
|||
{
|
||||
...fullMovie,
|
||||
uuid: dbMovie.uuid,
|
||||
createdAt: dbMovie.createdAt,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: library.uuid,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
'!=': '!=',
|
||||
|
|
|
|||
Loading…
Reference in a new issue