fix: sort nested program queries by index

Allows specification of sorting in the search API now
This commit is contained in:
Christian Benincasa
2026-01-16 12:49:39 -05:00
parent cdb1ba6c37
commit a0a13b3517
9 changed files with 60 additions and 156 deletions

File diff suppressed because one or more lines are too long

View File

@@ -85,6 +85,7 @@ export default defineConfig(
'warn',
{ allowConstantExport: true },
],
'react-hooks/preserve-manual-memoization': ['warn'],
// Don't error on promise-returning functions in JSX attributes
'@typescript-eslint/no-misused-promises': [
2,

View File

@@ -45,6 +45,7 @@ export class SearchProgramsCommand {
restrictSearchTo: req.query
.restrictSearchTo as Path<ProgramSearchDocument>[],
facets: ['type'],
sort: req.query.sort ?? undefined,
});
const [programIds, groupingIds] = result.results.reduce(

View File

@@ -164,6 +164,7 @@ const ProgramsIndex: TunarrSearchIndex<ProgramSearchDocument> = {
],
sortable: [
'title',
'sortTitle',
'duration',
'originalReleaseDate',
'originalReleaseYear',
@@ -257,6 +258,7 @@ type BaseProgramSearchDocument = {
libraryId: SingleCaseString;
title: string;
titleReverse: string;
sortTitle?: string;
rating: Nullable<string>;
summary: Nullable<string>;
plot: Nullable<string>;
@@ -333,6 +335,7 @@ type SearchRequest<
page: number;
limit: number;
};
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
};
export type FreeSearchResponse<DocumentType extends Record<string, unknown>> = {
@@ -777,6 +780,7 @@ export class MeilisearchService implements ISearchService {
tagline: show.tagline,
title: show.title,
titleReverse: show.title.split('').reverse().join(''),
sortTitle: show.sortTitle,
rating: show.rating,
genres: show.genres,
actors: show.actors,
@@ -824,6 +828,7 @@ export class MeilisearchService implements ISearchService {
tagline: season.tagline,
title: season.title,
titleReverse: season.title.split('').reverse().join(''),
sortTitle: season.sortTitle,
director: [],
rating: null,
actors: [],
@@ -948,6 +953,7 @@ export class MeilisearchService implements ISearchService {
tagline: artist.tagline,
title: artist.title,
titleReverse: artist.title.split('').reverse().join(''),
sortTitle: artist.sortTitle,
rating: null,
genres: artist.genres ?? [],
actors: [],
@@ -993,6 +999,7 @@ export class MeilisearchService implements ISearchService {
tagline: album.tagline,
title: album.title,
titleReverse: album.title.split('').reverse().join(''),
sortTitle: album.sortTitle,
director: [],
rating: null,
actors: [],
@@ -1138,19 +1145,24 @@ export class MeilisearchService implements ISearchService {
}
if (isNonEmptyString(request.query)) {
const sort = request.sort?.map(
({ field, direction }) => `${field}:${direction}`,
) ?? ['title:asc'];
const req = {
filter,
page: request.paging?.page,
limit: request.paging?.limit,
attributesToSearchOn: request.restrictSearchTo ?? undefined,
facets: request.facets ?? undefined,
sort,
} satisfies SearchParams;
this.logger.debug(
'Issuing search: query = %s, filter: %O (parsed: %O)',
'Issuing search: query = %s, filter: %O (parsed: %O), sort %O',
request.query,
request.filter ?? {},
req,
sort,
);
const searchResults = await this.client()
@@ -1165,9 +1177,14 @@ export class MeilisearchService implements ISearchService {
const offset = request.paging
? request.paging.page * request.paging.limit
: undefined;
const sort = request.sort?.map(
({ field, direction }) => `${field}:${direction}`,
) ?? ['title:asc'];
this.logger.debug(
'Issuing get documents request: filter: "%s". offset: %d limit %d',
'Issuing get documents request: filter: "%s", sort: %O. offset: %d limit %d',
filter ?? '',
sort,
offset ?? 0,
request.paging?.limit ?? -1,
);
@@ -1180,7 +1197,8 @@ export class MeilisearchService implements ISearchService {
offset,
// This does not exist on the type yet. Explicit cast because
// the API supports it. Need https://github.com/meilisearch/meilisearch-js/pull/2038
sort: ['title:asc' /*, 'originalReleaseDate:asc'*/],
// sort: ['title:asc' /*, 'originalReleaseDate:asc'*/],
sort,
} as DocumentsQuery<IndexDocumentTypeT>);
return {
type: 'filter',
@@ -1502,6 +1520,7 @@ export class MeilisearchService implements ISearchService {
tagline: program.type === 'movie' ? program.tagline : null,
title: program.title,
titleReverse: program.title.split('').reverse().join(''),
sortTitle: program.sortTitle,
type: program.type,
index:
program.type === 'episode'

View File

@@ -107,8 +107,19 @@ export const SearchFilterQuerySchema: z.ZodDiscriminatedUnion<
export type SearchFilter = z.infer<typeof SearchFilterQuerySchema>;
export const SearchSortFields = [
'title',
'sortTitle',
'duration',
'originalReleaseDate',
'originalReleaseYear',
'index',
] as const;
export type SearchSortField = TupleToUnion<typeof SearchSortFields>;
export const SearchSortSchema = z.object({
field: z.string(),
field: z.enum(SearchSortFields),
direction: z.enum(['asc', 'desc']),
});
@@ -118,7 +129,7 @@ export const SearchRequestSchema = z.object({
query: z.string().nullish(),
restrictSearchTo: z.string().array().optional(),
filter: SearchFilterQuerySchema.nullish(),
sort: SearchSortSchema.nullish(),
sort: SearchSortSchema.array().nullish(),
});
export type SearchRequest = z.infer<typeof SearchRequestSchema>;

View File

@@ -8,7 +8,7 @@ import type {
ProgramOrFolder,
} from '@tunarr/types';
import { type ProgramLike } from '@tunarr/types';
import type { SearchFilter } from '@tunarr/types/api';
import type { SearchFilter, SearchSort } from '@tunarr/types/api';
import {
type ProgramSearchResponse,
type SearchRequest,
@@ -87,8 +87,17 @@ export const LibraryProgramGrid = ({
const query = useMemo<SearchRequest>(() => {
if (currentParentContext) {
// sort override
const sort = match(currentParentContext)
.returnType<Maybe<SearchSort[]>>()
.with({ type: P.union('show', 'season', 'album') }, () => [
{ field: 'index', direction: 'asc' },
])
.otherwise(() => undefined);
const filter = getChildSearchFilter(currentParentContext);
return {
filter: getChildSearchFilter(currentParentContext),
filter,
sort,
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1811,77 +1811,6 @@ export type PostApiTasksByIdRunResponses = {
202: unknown;
};
export type GetApiJobsData = {
body?: never;
path?: never;
query?: never;
url: '/api/jobs';
};
export type GetApiJobsResponses = {
/**
* Default Response
*/
200: Array<{
id: string;
name: string;
description?: string;
scheduledTasks: Array<{
running: boolean;
lastExecution?: string;
lastExecutionEpoch?: number;
nextExecution?: string;
nextExecutionEpoch?: number;
args: {
[key: string]: never;
} | string;
}>;
}>;
};
export type GetApiJobsResponse = GetApiJobsResponses[keyof GetApiJobsResponses];
export type PostApiJobsByIdRunData = {
body?: never;
path: {
id: string;
};
query?: {
background?: string;
};
url: '/api/jobs/{id}/run';
};
export type PostApiJobsByIdRunErrors = {
/**
* Default Response
*/
400: {
message: string;
};
/**
* Default Response
*/
404: unknown;
/**
* Default Response
*/
500: unknown;
};
export type PostApiJobsByIdRunError = PostApiJobsByIdRunErrors[keyof PostApiJobsByIdRunErrors];
export type PostApiJobsByIdRunResponses = {
/**
* Default Response
*/
200: unknown;
/**
* Default Response
*/
202: unknown;
};
export type GetChannelsData = {
body?: never;
path?: never;
@@ -11821,10 +11750,10 @@ export type PostApiProgramsSearchData = {
query?: string | null;
restrictSearchTo?: Array<string>;
filter?: SearchFilterInput | null;
sort?: {
field: string;
sort?: Array<{
field: 'title' | 'sortTitle' | 'duration' | 'originalReleaseDate' | 'originalReleaseYear' | 'index';
direction: 'asc' | 'desc';
} | null;
}> | null;
};
restrictSeachTo?: Array<string>;
mediaSourceId?: string;
@@ -17694,7 +17623,7 @@ export type GetApiPlexByMediaSourceIdFiltersResponses = {
Meta: {
Type: Array<{
key: string;
type: 'movie' | 'show' | 'artist' | 'photo' | 'track' | 'season' | 'album' | 'folder';
type: 'movie' | 'show' | 'artist' | 'photo' | 'track' | 'episode' | 'season' | 'album' | 'folder';
title: string;
active: boolean;
Filter?: Array<{