Files
tunarr/web/src/components/library/LibraryProgramGrid.tsx
Christian Benincasa a0a13b3517 fix: sort nested program queries by index
Allows specification of sorting in the search API now
2026-01-16 12:49:39 -05:00

271 lines
8.0 KiB
TypeScript

import { Box, LinearProgress, Typography } from '@mui/material';
import { useInfiniteQuery } from '@tanstack/react-query';
import { isNonEmptyString } from '@tunarr/shared/util';
import type {
MediaSourceContentType,
MediaSourceLibrary,
MediaSourceSettings,
ProgramOrFolder,
} from '@tunarr/types';
import { type ProgramLike } from '@tunarr/types';
import type { SearchFilter, SearchSort } from '@tunarr/types/api';
import {
type ProgramSearchResponse,
type SearchRequest,
} from '@tunarr/types/api';
import { groupBy, isEmpty, isUndefined, last } from 'lodash-es';
import { useCallback, useEffect, useMemo } from 'react';
import { match, P } from 'ts-pattern';
import { postApiProgramsSearch } from '../../generated/sdk.gen.ts';
import { useProgramHierarchy } from '../../hooks/channel_config/useProgramHierarchy.ts';
import { getChildSearchFilter } from '../../hooks/useProgramSearch.ts';
import useStore from '../../store/index.ts';
import { addKnownMediaForServer } from '../../store/programmingSelector/actions.ts';
import type { Maybe } from '../../types/util.ts';
import type { RenderNestedGrid } from '../channel_config/MediaItemGrid.tsx';
import {
MediaItemGrid,
type GridItemProps,
} from '../channel_config/MediaItemGrid.tsx';
import { MediaItemList } from '../channel_config/MediaItemList.tsx';
import { ProgramGridItem } from './ProgramGridItem.tsx';
import { ProgramListItem } from './ProgramListItem.tsx';
type Props = {
mediaSource?: MediaSourceSettings;
library?: MediaSourceLibrary;
disableProgramSelection?: boolean;
depth?: number;
parentContext?: ProgramOrFolder[];
searchRequest?: SearchRequest;
};
function searchItemTypeFromContentType(
mediaType: MediaSourceContentType,
): ProgramLike['type'] {
switch (mediaType) {
case 'movies':
return 'movie';
case 'shows':
return 'show';
case 'tracks':
return 'artist';
case 'other_videos':
return 'other_video';
case 'music_videos':
return 'music_video';
}
}
function typeFilter(mediaType: MediaSourceContentType): SearchFilter {
return {
type: 'value',
fieldSpec: {
key: 'type',
name: 'Type',
op: '=',
type: 'string',
value: [searchItemTypeFromContentType(mediaType)],
},
};
}
export const LibraryProgramGrid = ({
mediaSource,
library,
disableProgramSelection,
depth = 0,
parentContext = [],
searchRequest: staticSearchRequest,
}: Props) => {
const searchRequest = useStore((s) => s.currentSearchRequest);
const currentParentContext = last(parentContext);
const viewType = useStore((state) => state.theme.programmingSelectorView);
const programHierarchy = useProgramHierarchy(
useCallback((p: ProgramOrFolder) => p.uuid, []),
);
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,
sort,
};
}
if (staticSearchRequest) {
return staticSearchRequest;
}
const filter = match([searchRequest?.filter, mediaSource, library])
.returnType<SearchFilter | null>()
.with([P.select(P.nonNullable), P._, P._], (filter) => filter)
.with([P._, { mediaType: P.select(P.nonNullable) }, P.nullish], (typ) =>
typeFilter(typ),
)
.with([P._, P._, P.select(P.nonNullable)], ({ mediaType }) =>
typeFilter(mediaType),
)
.otherwise(() => null);
return {
query: searchRequest?.query,
filter,
restrictSearchTo: searchRequest?.restrictSearchTo,
};
}, [
currentParentContext,
library,
mediaSource,
searchRequest?.filter,
searchRequest?.query,
searchRequest?.restrictSearchTo,
staticSearchRequest,
]);
const search = useInfiniteQuery({
queryKey: ['programs', 'search', query, mediaSource?.id, library?.id],
queryFn: async ({ pageParam }) => {
const { data } = await postApiProgramsSearch({
body: {
mediaSourceId: mediaSource?.id,
libraryId: library?.id,
query: query,
limit: 45,
page: pageParam,
},
throwOnError: true,
});
return data;
},
getNextPageParam: (last) => {
const isFreeQuery = isNonEmptyString(query.query);
const nextPage = last.page + 1;
if (isFreeQuery) {
// We can't always trust the total hits. Meilisearch
// by default maxes out at 1000 for search requests.
// You can configure this but it makes search slow. We
// just keep querying until there are no more results!
if (last.totalHits < 1_000 && nextPage > last.totalPages) {
return;
} else if (last.totalHits >= 1_000 && last.results.length === 0) {
return;
}
} else if (last.results.length === 0) {
return;
}
return nextPage;
},
getPreviousPageParam: (last) => {
const prevPage = last.page - 1;
if (prevPage <= 0) {
return;
}
return prevPage;
},
initialPageParam: undefined as Maybe<number>,
staleTime: 0,
});
useEffect(() => {
const allResults = search.data?.pages.flatMap((page) => {
return page.results;
});
if (!isEmpty(allResults)) {
const byMediaSourceId = groupBy(
allResults,
(result) => result.mediaSourceId,
);
for (const [mediaSourceId, results] of Object.entries(byMediaSourceId)) {
addKnownMediaForServer(mediaSourceId, results);
}
}
}, [search.data?.pages]);
const renderGridItem = (gridItemProps: GridItemProps<ProgramOrFolder>) => {
return (
<ProgramGridItem
key={gridItemProps.item.uuid}
disableSelection={disableProgramSelection}
persisted
{...gridItemProps}
/>
);
};
const renderNestedGrid: RenderNestedGrid<ProgramOrFolder> = useCallback(
(props) => {
return (
<LibraryProgramGrid
{...props}
parentContext={props.parent ? [props.parent] : []}
mediaSource={mediaSource}
library={library}
disableProgramSelection={disableProgramSelection}
/>
);
},
[mediaSource, library, disableProgramSelection],
);
const totalHits = search.data?.pages?.[0].totalHits;
return (
<Box sx={{ mt: 1 }}>
{depth === 0 && !isUndefined(totalHits) && (
<Typography textAlign="right" variant="subtitle2">
Total hits:{' '}
{isNonEmptyString(query.query) && totalHits >= 1000
? '>1000'
: totalHits}
</Typography>
)}
{search.isLoading && <LinearProgress />}
{viewType === 'grid' ? (
<MediaItemGrid
depth={depth}
infiniteQuery={search}
renderGridItem={renderGridItem}
renderNestedGrid={renderNestedGrid}
extractItems={(page: ProgramSearchResponse) => page.results}
getItemKey={(result: ProgramOrFolder) => result.uuid}
getPageDataSize={(page: ProgramSearchResponse) => ({
size: page.results.length,
total: page.totalHits,
})}
/>
) : (
<MediaItemList
infiniteQuery={search}
extractItems={(page: ProgramSearchResponse) => page.results}
getPageDataSize={(page: ProgramSearchResponse) => ({
size: page.results.length,
total: page.totalHits,
})}
renderListItem={({ item, index, style }) => (
<ProgramListItem
key={item.uuid}
item={item}
index={index}
style={style}
onPushParent={programHierarchy.pushParentContext}
disableSelection={disableProgramSelection}
/>
)}
/>
)}
</Box>
);
};