mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: add relative date search fields
This commit is contained in:
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 ?? [],
|
||||
@@ -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,
|
||||
|
||||
@@ -149,6 +149,7 @@ export abstract class MediaSourceMovieLibraryScanner<
|
||||
{
|
||||
...fullMovie,
|
||||
uuid: dbMovie.uuid,
|
||||
createdAt: dbMovie.createdAt,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: library.uuid,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
'!=': '!=',
|
||||
|
||||
Reference in New Issue
Block a user