1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge pull request #6593 from viown/search-enhancements

Search Enhancements
This commit is contained in:
Bill Thornton 2025-03-26 18:54:09 -04:00 committed by GitHub
commit fcf344cea3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 713 additions and 546 deletions

View file

@ -0,0 +1,23 @@
import { Api } from '@jellyfin/sdk/lib/api';
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { AxiosRequestConfig } from 'axios';
import { QUERY_OPTIONS } from '../constants/queryOptions';
export const fetchItemsByType = async (
api: Api,
userId?: string,
params?: ItemsApiGetItemsRequest,
options?: AxiosRequestConfig
) => {
const response = await getItemsApi(api).getItems(
{
...QUERY_OPTIONS,
userId,
recursive: true,
...params
},
options
);
return response.data;
};

View file

@ -0,0 +1,49 @@
import { Api } from '@jellyfin/sdk';
import { ArtistsApiGetArtistsRequest } from '@jellyfin/sdk/lib/generated-client/api/artists-api';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QUERY_OPTIONS } from '../constants/queryOptions';
import { isMusic } from '../utils/search';
const fetchArtists = async (
api: Api,
userId: string,
params?: ArtistsApiGetArtistsRequest,
options?: AxiosRequestConfig
) => {
const response = await getArtistsApi(api).getArtists(
{
...QUERY_OPTIONS,
userId,
...params
},
options
);
return response.data;
};
export const useArtistsSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'Artists', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchArtists(
api!,
userId!,
{
parentId,
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && (!collectionType || isMusic(collectionType))
});
};

View file

@ -0,0 +1,150 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { addSection, isLivetv } from '../utils/search';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
import { CardShape } from 'utils/card';
import { Section } from '../types';
import { fetchItemsByType } from './fetchItemsByType';
const fetchLiveTv = (api: Api, userId: string | undefined, searchTerm: string | undefined, signal: AbortSignal) => {
const sections: Section[] = [];
// Movies row
const movies = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isMovie: true,
searchTerm
},
{ signal }
).then(moviesData => {
addSection(sections, 'Movies', moviesData.Items, {
...LIVETV_CARD_OPTIONS,
shape: CardShape.PortraitOverflow
});
});
// Episodes row
const episodes = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isMovie: false,
isSeries: true,
isSports: false,
isKids: false,
isNews: false,
searchTerm
},
{ signal }
).then(episodesData => {
addSection(sections, 'Episodes', episodesData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Sports row
const sports = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isSports: true,
searchTerm
},
{ signal }
).then(sportsData => {
addSection(sections, 'Sports', sportsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Kids row
const kids = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isKids: true,
searchTerm
},
{ signal }
).then(kidsData => {
addSection(sections, 'Kids', kidsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// News row
const news = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isNews: true,
searchTerm
},
{ signal }
).then(newsData => {
addSection(sections, 'News', newsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Programs row
const programs = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isMovie: false,
isSeries: false,
isSports: false,
isKids: false,
isNews: false,
searchTerm
},
{ signal }
).then(programsData => {
addSection(sections, 'Programs', programsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Channels row
const channels = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.TvChannel ],
searchTerm
},
{ signal }
).then(channelsData => {
addSection(sections, 'Channels', channelsData.Items);
});
return Promise.all([ movies, episodes, sports, kids, news, programs, channels ]).then(() => sections);
};
export const useLiveTvSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'LiveTv', collectionType, parentId, searchTerm],
queryFn: ({ signal }) =>
fetchLiveTv(api!, userId!, searchTerm, signal),
enabled: !!api && !!userId && !!collectionType && !!isLivetv(collectionType)
});
};

View file

@ -0,0 +1,50 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QUERY_OPTIONS } from '../constants/queryOptions';
import { isMovies, isTVShows } from '../utils/search';
import { PersonsApiGetPersonsRequest } from '@jellyfin/sdk/lib/generated-client/api/persons-api';
import { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api';
const fetchPeople = async (
api: Api,
userId: string,
params?: PersonsApiGetPersonsRequest,
options?: AxiosRequestConfig
) => {
const response = await getPersonsApi(api).getPersons(
{
...QUERY_OPTIONS,
userId,
...params
},
options
);
return response.data;
};
export const usePeopleSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
const isPeopleEnabled = (!collectionType || isMovies(collectionType) || isTVShows(collectionType));
return useQuery({
queryKey: ['Search', 'People', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchPeople(
api!,
userId!,
{
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && isPeopleEnabled
});
};

View file

@ -0,0 +1,50 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
import { fetchItemsByType } from './fetchItemsByType';
const fetchPrograms = async (
api: Api,
userId: string,
params?: ItemsApiGetItemsRequest,
options?: AxiosRequestConfig
) => {
const response = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
...params
},
options
);
return response;
};
export const useProgramsSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'Programs', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchPrograms(
api!,
userId!,
{
parentId,
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && !collectionType
});
};

View file

@ -0,0 +1,98 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../../../../hooks/useApi';
import { addSection, getCardOptionsFromType, getItemTypesFromCollectionType, getTitleFromType, isLivetv, isMovies, isMusic, isTVShows, sortSections } from '../utils/search';
import { useArtistsSearch } from './useArtistsSearch';
import { usePeopleSearch } from './usePeopleSearch';
import { useVideoSearch } from './useVideoSearch';
import { Section } from '../types';
import { useLiveTvSearch } from './useLiveTvSearch';
import { fetchItemsByType } from './fetchItemsByType';
import { useProgramsSearch } from './useProgramsSearch';
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
export const useSearchItems = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { data: artists, isPending: isArtistsPending } = useArtistsSearch(parentId, collectionType, searchTerm);
const { data: people, isPending: isPeoplePending } = usePeopleSearch(parentId, collectionType, searchTerm);
const { data: videos, isPending: isVideosPending } = useVideoSearch(parentId, collectionType, searchTerm);
const { data: programs, isPending: isProgramsPending } = useProgramsSearch(parentId, collectionType, searchTerm);
const { data: liveTvSections, isPending: isLiveTvPending } = useLiveTvSearch(parentId, collectionType, searchTerm);
const { api, user } = useApi();
const userId = user?.Id;
const isArtistsEnabled = !isArtistsPending || (collectionType && !isMusic(collectionType));
const isPeopleEnabled = !isPeoplePending || (collectionType && !isMovies(collectionType) && !isTVShows(collectionType));
const isVideosEnabled = !isVideosPending || collectionType;
const isProgramsEnabled = !isProgramsPending || collectionType;
const isLiveTvEnabled = !isLiveTvPending || !collectionType || !isLivetv(collectionType);
return useQuery({
queryKey: ['Search', 'Items', collectionType, parentId, searchTerm],
queryFn: async ({ signal }) => {
if (liveTvSections && collectionType && isLivetv(collectionType)) {
return sortSections(liveTvSections);
}
const sections: Section[] = [];
addSection(sections, 'Artists', artists?.Items, {
coverImage: true
});
addSection(sections, 'Programs', programs?.Items, {
...LIVETV_CARD_OPTIONS
});
addSection(sections, 'People', people?.Items, {
coverImage: true
});
addSection(sections, 'HeaderVideos', videos?.Items, {
showParentTitle: true
});
const itemTypes: BaseItemKind[] = getItemTypesFromCollectionType(collectionType);
const searchData = await fetchItemsByType(
api!,
userId,
{
includeItemTypes: itemTypes,
parentId,
searchTerm,
limit: 800
},
{ signal }
);
if (searchData.Items) {
for (const itemType of itemTypes) {
const items: BaseItemDto[] = [];
for (const searchItem of searchData.Items) {
if (searchItem.Type === itemType) {
items.push(searchItem);
}
}
addSection(sections, getTitleFromType(itemType), items, getCardOptionsFromType(itemType));
}
}
return sortSections(sections);
},
enabled: (
!!api
&& !!userId
&& !!isArtistsEnabled
&& !!isPeopleEnabled
&& !!isVideosEnabled
&& !!isLiveTvEnabled
&& !!isProgramsEnabled
)
});
};

View file

@ -0,0 +1,47 @@
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
const fetchGetItems = async (
api: Api,
userId: string,
parentId?: string,
options?: AxiosRequestConfig
) => {
const response = await getItemsApi(api).getItems(
{
userId,
sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random],
includeItemTypes: [
BaseItemKind.Movie,
BaseItemKind.Series,
BaseItemKind.MusicArtist
],
limit: 20,
recursive: true,
imageTypeLimit: 0,
enableImages: false,
parentId,
enableTotalRecordCount: false
},
options
);
return response.data.Items || [];
};
export const useSearchSuggestions = (parentId?: string) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['SearchSuggestions', { parentId }],
queryFn: ({ signal }) =>
fetchGetItems(api!, userId!, parentId, { signal }),
refetchOnWindowFocus: false,
enabled: !!api && !!userId
});
};

View file

@ -0,0 +1,57 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QUERY_OPTIONS } from '../constants/queryOptions';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
const fetchVideos = async (
api: Api,
userId: string,
params?: ItemsApiGetItemsRequest,
options?: AxiosRequestConfig
) => {
const response = await getItemsApi(api).getItems(
{
...QUERY_OPTIONS,
userId,
recursive: true,
mediaTypes: [MediaType.Video],
excludeItemTypes: [
BaseItemKind.Movie,
BaseItemKind.Episode,
BaseItemKind.TvChannel
],
...params
},
options
);
return response.data;
};
export const useVideoSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'Video', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchVideos(
api!,
userId!,
{
parentId,
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && !collectionType
});
};

View file

@ -0,0 +1,68 @@
import React, { type ChangeEvent, type FC, useCallback, useRef } from 'react';
import AlphaPicker from 'components/alphaPicker/AlphaPickerComponent';
import Input from 'elements/emby-input/Input';
import globalize from 'lib/globalize';
import layoutManager from 'components/layoutManager';
import browser from 'scripts/browser';
import 'material-design-icons-iconfont';
import 'styles/flexstyles.scss';
import './searchfields.scss';
interface SearchFieldsProps {
query: string,
onSearch?: (query: string) => void
}
const SearchFields: FC<SearchFieldsProps> = ({
onSearch = () => { /* no-op */ },
query
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const onAlphaPicked = useCallback((e: Event) => {
const value = (e as CustomEvent).detail.value;
const inputValue = inputRef.current?.value || '';
if (value === 'backspace') {
onSearch(inputValue.length ? inputValue.substring(0, inputValue.length - 1) : '');
} else {
onSearch(inputValue + value);
}
}, [onSearch]);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
}, [ onSearch ]);
return (
<div className='padded-left padded-right searchFields'>
<div className='searchFieldsInner flex align-items-center justify-content-center'>
<span className='searchfields-icon material-icons search' aria-hidden='true' />
<div
className='inputContainer flex-grow'
style={{ marginBottom: 0 }}
>
<Input
ref={inputRef}
id='searchTextInput'
className='searchfields-txtSearch'
type='text'
data-keyboard='true'
placeholder={globalize.translate('Search')}
autoComplete='off'
maxLength={40}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={query}
onChange={onChange}
/>
</div>
</div>
{layoutManager.tv && !browser.tv
&& <AlphaPicker onAlphaPicked={onAlphaPicked} />
}
</div>
);
};
export default SearchFields;

View file

@ -0,0 +1,71 @@
import React, { type FC } from 'react';
import { useSearchItems } from '../api/useSearchItems';
import globalize from 'lib/globalize';
import Loading from 'components/loading/LoadingComponent';
import SearchResultsRow from './SearchResultsRow';
import { CardShape } from 'utils/card';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { Section } from '../types';
import { Link } from 'react-router-dom';
interface SearchResultsProps {
parentId?: string;
collectionType?: CollectionType;
query?: string;
}
/*
* React component to display search result rows for global search and library view search
*/
const SearchResults: FC<SearchResultsProps> = ({
parentId,
collectionType,
query
}) => {
const { data, isPending } = useSearchItems(parentId, collectionType, query?.trim());
if (isPending) return <Loading />;
if (!data?.length) {
return (
<div className='noItemsMessage centerMessage'>
{globalize.translate('SearchResultsEmpty', query)}
{collectionType && (
<div>
<Link
className='emby-button'
to={`/search.html?query=${encodeURIComponent(query || '')}`}
>{globalize.translate('RetryWithGlobalSearch')}</Link>
</div>
)}
</div>
);
}
const renderSection = (section: Section, index: number) => {
return (
<SearchResultsRow
key={`${section.title}-${index}`}
title={globalize.translate(section.title)}
items={section.items}
cardOptions={{
shape: CardShape.AutoOverflow,
scalable: true,
showTitle: true,
overlayText: false,
centerText: true,
allowBottomPadding: false,
...section.cardOptions
}}
/>
);
};
return (
<div className={'searchResults padded-top padded-bottom-page'}>
{data.map((section, index) => renderSection(section, index))}
</div>
);
};
export default SearchResults;

View file

@ -0,0 +1,44 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import React, { type FC, useEffect, useRef } from 'react';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import type { CardOptions } from 'types/cardOptions';
import 'elements/emby-scroller/emby-scroller';
import 'elements/emby-itemscontainer/emby-itemscontainer';
// There seems to be some compatibility issues here between
// React and our legacy web components, so we need to inject
// them as an html string for now =/
const createScroller = ({ title = '' }) => ({
__html: `<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${title}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>`
});
interface SearchResultsRowProps {
title?: string;
items?: BaseItemDto[];
cardOptions?: CardOptions;
}
const SearchResultsRow: FC<SearchResultsRowProps> = ({ title, items = [], cardOptions = {} }) => {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
cardBuilder.buildCards(items, {
itemsContainer: element.current?.querySelector('.itemsContainer'),
...cardOptions
});
}, [cardOptions, items]);
return (
<div
ref={element}
className='verticalSection'
dangerouslySetInnerHTML={createScroller({ title })}
/>
);
};
export default SearchResultsRow;

View file

@ -0,0 +1,48 @@
import React, { FunctionComponent } from 'react';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import { useSearchSuggestions } from '../api/useSearchSuggestions';
import globalize from 'lib/globalize';
import LinkButton from 'elements/emby-button/LinkButton';
import 'elements/emby-button/emby-button';
type SearchSuggestionsProps = {
parentId?: string | null;
};
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId }) => {
const { data: suggestions, isPending } = useSearchSuggestions(parentId || undefined);
if (isPending) return <Loading />;
return (
<div
className='verticalSection searchSuggestions'
style={{ textAlign: 'center' }}
>
<div>
<h2 className='sectionTitle padded-left padded-right'>
{globalize.translate('Suggestions')}
</h2>
</div>
<div className='searchSuggestionsList padded-left padded-right'>
{suggestions?.map(item => (
<div key={item.Id}>
<LinkButton
className='button-link'
style={{ display: 'inline-block', padding: '0.5em 1em' }}
href={appRouter.getRouteUrl(item)}
>
{item.Name}
</LinkButton>
</div>
))}
</div>
</div>
);
};
export default SearchSuggestions;

View file

@ -0,0 +1,11 @@
.searchFieldsInner {
max-width: 60em;
margin: 0 auto;
}
.searchfields-icon {
margin-bottom: 0.1em;
margin-right: 0.25em;
font-size: 2em;
align-self: flex-end;
}

View file

@ -0,0 +1,11 @@
export const LIVETV_CARD_OPTIONS = {
preferThumb: true,
inheritThumb: false,
showParentTitleOrTitle: true,
showTitle: false,
coverImage: true,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
};

View file

@ -0,0 +1,12 @@
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
export const QUERY_OPTIONS = {
limit: 100,
fields: [
ItemFields.PrimaryImageAspectRatio,
ItemFields.CanDelete,
ItemFields.MediaSourceCount
],
enableTotalRecordCount: false,
imageTypeLimit: 1
};

View file

@ -0,0 +1,18 @@
export const SEARCH_SECTIONS_SORT_ORDER = [
'Movies',
'Shows',
'Episodes',
'People',
'Playlists',
'Artists',
'Albums',
'Songs',
'HeaderVideos',
'Programs',
'Channels',
'HeaderPhotoAlbums',
'Photos',
'HeaderAudioBooks',
'Books',
'Collections'
];

View file

@ -0,0 +1,8 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { CardOptions } from 'types/cardOptions';
export interface Section {
title: string
items: BaseItemDto[];
cardOptions?: CardOptions;
};

View file

@ -0,0 +1,141 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { CardShape } from 'utils/card';
import { Section } from '../types';
import { CardOptions } from 'types/cardOptions';
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
import { SEARCH_SECTIONS_SORT_ORDER } from '../constants/sectionSortOrder';
export const isMovies = (collectionType: string) =>
collectionType === CollectionType.Movies;
export const isTVShows = (collectionType: string) =>
collectionType === CollectionType.Tvshows;
export const isMusic = (collectionType: string) =>
collectionType === CollectionType.Music;
export const isLivetv = (collectionType: string) =>
collectionType === CollectionType.Livetv;
export function addSection(
sections: Section[],
title: string,
items: BaseItemDto[] | null | undefined,
cardOptions?: CardOptions
) {
if (items && items?.length > 0) {
sections.push({ title, items, cardOptions });
}
}
export function sortSections(sections: Section[]) {
return sections.sort((a, b) => {
const indexA = SEARCH_SECTIONS_SORT_ORDER.indexOf(a.title);
const indexB = SEARCH_SECTIONS_SORT_ORDER.indexOf(b.title);
if (indexA > indexB) {
return 1;
} else if (indexA < indexB) {
return -1;
} else {
return 0;
}
});
}
export function getCardOptionsFromType(type: BaseItemKind) {
switch (type) {
case BaseItemKind.Movie:
case BaseItemKind.Series:
case BaseItemKind.MusicAlbum:
return {
showYear: true
};
case BaseItemKind.Episode:
return {
coverImage: true,
showParentTitle: true
};
case BaseItemKind.MusicArtist:
return {
coverImage: true
};
case BaseItemKind.Audio:
return {
showParentTitle: true,
shape: CardShape.SquareOverflow
};
case BaseItemKind.LiveTvProgram:
return LIVETV_CARD_OPTIONS;
default:
return {};
}
}
export function getTitleFromType(type: BaseItemKind) {
switch (type) {
case BaseItemKind.Movie:
return 'Movies';
case BaseItemKind.Series:
return 'Shows';
case BaseItemKind.Episode:
return 'Episodes';
case BaseItemKind.Playlist:
return 'Playlists';
case BaseItemKind.MusicAlbum:
return 'Albums';
case BaseItemKind.Audio:
return 'Songs';
case BaseItemKind.LiveTvProgram:
return 'Programs';
case BaseItemKind.TvChannel:
return 'Channels';
case BaseItemKind.PhotoAlbum:
return 'HeaderPhotoAlbums';
case BaseItemKind.Photo:
return 'Photos';
case BaseItemKind.AudioBook:
return 'HeaderAudioBooks';
case BaseItemKind.Book:
return 'Books';
case BaseItemKind.BoxSet:
return 'Collections';
default:
return '';
}
}
export function getItemTypesFromCollectionType(collectionType: CollectionType | undefined) {
switch (collectionType) {
case CollectionType.Movies:
return [ BaseItemKind.Movie ];
case CollectionType.Tvshows:
return [
BaseItemKind.Series,
BaseItemKind.Episode
];
case CollectionType.Music:
return [
BaseItemKind.Playlist,
BaseItemKind.MusicAlbum,
BaseItemKind.Audio
];
default:
return [
BaseItemKind.Movie,
BaseItemKind.Series,
BaseItemKind.Episode,
BaseItemKind.Playlist,
BaseItemKind.MusicAlbum,
BaseItemKind.Audio,
BaseItemKind.TvChannel,
BaseItemKind.PhotoAlbum,
BaseItemKind.Photo,
BaseItemKind.AudioBook,
BaseItemKind.Book,
BaseItemKind.BoxSet
];
}
}

View file

@ -4,9 +4,10 @@ import { useDebounceValue } from 'usehooks-ts';
import { usePrevious } from 'hooks/usePrevious';
import globalize from 'lib/globalize';
import Page from 'components/Page';
import SearchFields from 'components/search/SearchFields';
import SearchSuggestions from 'components/search/SearchSuggestions';
import SearchResults from 'components/search/SearchResults';
import SearchFields from 'apps/stable/features/search/components/SearchFields';
import SearchSuggestions from 'apps/stable/features/search/components/SearchSuggestions';
import SearchResults from 'apps/stable/features/search/components/SearchResults';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
const COLLECTION_TYPE_PARAM = 'collectionType';
const PARENT_ID_PARAM = 'parentId';
@ -15,7 +16,7 @@ const QUERY_PARAM = 'query';
const Search: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const parentIdQuery = searchParams.get(PARENT_ID_PARAM) || undefined;
const collectionTypeQuery = searchParams.get(COLLECTION_TYPE_PARAM) || undefined;
const collectionTypeQuery = (searchParams.get(COLLECTION_TYPE_PARAM) || undefined) as CollectionType | undefined;
const urlQuery = searchParams.get(QUERY_PARAM) || '';
const [query, setQuery] = useState(urlQuery);
const prevQuery = usePrevious(query, '');
@ -50,7 +51,7 @@ const Search: FC = () => {
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
>
<SearchFields query={query} onSearch={setQuery} />
{!query ? (
{!debouncedQuery ? (
<SearchSuggestions
parentId={parentIdQuery}
/>