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', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
'react-hooks/preserve-manual-memoization': ['warn'],
// Don't error on promise-returning functions in JSX attributes // Don't error on promise-returning functions in JSX attributes
'@typescript-eslint/no-misused-promises': [ '@typescript-eslint/no-misused-promises': [
2, 2,

View File

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

View File

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

View File

@@ -107,8 +107,19 @@ export const SearchFilterQuerySchema: z.ZodDiscriminatedUnion<
export type SearchFilter = z.infer<typeof SearchFilterQuerySchema>; 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({ export const SearchSortSchema = z.object({
field: z.string(), field: z.enum(SearchSortFields),
direction: z.enum(['asc', 'desc']), direction: z.enum(['asc', 'desc']),
}); });
@@ -118,7 +129,7 @@ export const SearchRequestSchema = z.object({
query: z.string().nullish(), query: z.string().nullish(),
restrictSearchTo: z.string().array().optional(), restrictSearchTo: z.string().array().optional(),
filter: SearchFilterQuerySchema.nullish(), filter: SearchFilterQuerySchema.nullish(),
sort: SearchSortSchema.nullish(), sort: SearchSortSchema.array().nullish(),
}); });
export type SearchRequest = z.infer<typeof SearchRequestSchema>; export type SearchRequest = z.infer<typeof SearchRequestSchema>;

View File

@@ -8,7 +8,7 @@ import type {
ProgramOrFolder, ProgramOrFolder,
} from '@tunarr/types'; } from '@tunarr/types';
import { type ProgramLike } 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 { import {
type ProgramSearchResponse, type ProgramSearchResponse,
type SearchRequest, type SearchRequest,
@@ -87,8 +87,17 @@ export const LibraryProgramGrid = ({
const query = useMemo<SearchRequest>(() => { const query = useMemo<SearchRequest>(() => {
if (currentParentContext) { 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 { 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; 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 = { export type GetChannelsData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -11821,10 +11750,10 @@ export type PostApiProgramsSearchData = {
query?: string | null; query?: string | null;
restrictSearchTo?: Array<string>; restrictSearchTo?: Array<string>;
filter?: SearchFilterInput | null; filter?: SearchFilterInput | null;
sort?: { sort?: Array<{
field: string; field: 'title' | 'sortTitle' | 'duration' | 'originalReleaseDate' | 'originalReleaseYear' | 'index';
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
} | null; }> | null;
}; };
restrictSeachTo?: Array<string>; restrictSeachTo?: Array<string>;
mediaSourceId?: string; mediaSourceId?: string;
@@ -17694,7 +17623,7 @@ export type GetApiPlexByMediaSourceIdFiltersResponses = {
Meta: { Meta: {
Type: Array<{ Type: Array<{
key: string; key: string;
type: 'movie' | 'show' | 'artist' | 'photo' | 'track' | 'season' | 'album' | 'folder'; type: 'movie' | 'show' | 'artist' | 'photo' | 'track' | 'episode' | 'season' | 'album' | 'folder';
title: string; title: string;
active: boolean; active: boolean;
Filter?: Array<{ Filter?: Array<{