diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 5c483c9a8c..9682a3fc01 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -1,24 +1,25 @@ import React, { type FC, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; - -import Page from 'components/Page'; -import SearchFields from 'components/search/SearchFields'; -import SearchResults from 'components/search/SearchResults'; -import SearchSuggestions from 'components/search/SearchSuggestions'; -import LiveTVSearchResults from 'components/search/LiveTVSearchResults'; +import { useDebounceValue } from 'usehooks-ts'; import { usePrevious } from 'hooks/usePrevious'; import globalize from 'scripts/globalize'; +import Page from 'components/Page'; +import SearchFields from 'components/search/SearchFields'; +import SearchSuggestions from 'components/search/SearchSuggestions'; +import SearchResults from 'components/search/SearchResults'; const COLLECTION_TYPE_PARAM = 'collectionType'; const PARENT_ID_PARAM = 'parentId'; const QUERY_PARAM = 'query'; -const SERVER_ID_PARAM = 'serverId'; const Search: FC = () => { - const [ searchParams, setSearchParams ] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const parentIdQuery = searchParams.get(PARENT_ID_PARAM) || undefined; + const collectionTypeQuery = searchParams.get(COLLECTION_TYPE_PARAM) || undefined; const urlQuery = searchParams.get(QUERY_PARAM) || ''; - const [ query, setQuery ] = useState(urlQuery); + const [query, setQuery] = useState(urlQuery); const prevQuery = usePrevious(query, ''); + const [debouncedQuery] = useDebounceValue(query, 500); useEffect(() => { if (query !== prevQuery) { @@ -49,23 +50,17 @@ const Search: FC = () => { className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage' > - {!query - && - } - - + ) : ( + + )} ); }; diff --git a/src/components/search/LiveTVSearchResults.tsx b/src/components/search/LiveTVSearchResults.tsx deleted file mode 100644 index f26464ff22..0000000000 --- a/src/components/search/LiveTVSearchResults.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; -import type { ApiClient } from 'jellyfin-apiclient'; -import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; -import classNames from 'classnames'; -import React, { type FC, useCallback, useEffect, useState } from 'react'; -import { useDebounceValue } from 'usehooks-ts'; - -import globalize from '../../scripts/globalize'; -import ServerConnections from '../ServerConnections'; -import SearchResultsRow from './SearchResultsRow'; - -const CARD_OPTIONS = { - preferThumb: true, - inheritThumb: false, - showParentTitleOrTitle: true, - showTitle: false, - coverImage: true, - overlayMoreButton: true, - showAirTime: true, - showAirDateTime: true, - showChannelName: true -}; - -type LiveTVSearchResultsProps = { - serverId?: string; - parentId?: string | null; - collectionType?: string | null; - query?: string; -}; - -/* - * React component to display search result rows for live tv library search - */ -const LiveTVSearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { - const [ movies, setMovies ] = useState([]); - const [ episodes, setEpisodes ] = useState([]); - const [ sports, setSports ] = useState([]); - const [ kids, setKids ] = useState([]); - const [ news, setNews ] = useState([]); - const [ programs, setPrograms ] = useState([]); - const [ channels, setChannels ] = useState([]); - const [ debouncedQuery ] = useDebounceValue(query, 500); - - const getDefaultParameters = useCallback(() => ({ - ParentId: parentId, - searchTerm: debouncedQuery, - Limit: 24, - Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', - Recursive: true, - EnableTotalRecordCount: false, - ImageTypeLimit: 1, - IncludePeople: false, - IncludeMedia: false, - IncludeGenres: false, - IncludeStudios: false, - IncludeArtists: false - }), [ parentId, debouncedQuery ]); - - useEffect(() => { - const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems( - apiClient?.getCurrentUserId(), - { - ...getDefaultParameters(), - IncludeMedia: true, - ...params - } - ); - - // Reset state - setMovies([]); - setEpisodes([]); - setSports([]); - setKids([]); - setNews([]); - setPrograms([]); - setChannels([]); - - if (!debouncedQuery || collectionType !== CollectionType.Livetv) { - return; - } - - const apiClient = ServerConnections.getApiClient(serverId); - - // Movies row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: true - }) - .then(result => setMovies(result.Items || [])) - .catch(() => setMovies([])); - // Episodes row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: false, - IsSeries: true, - IsSports: false, - IsKids: false, - IsNews: false - }) - .then(result => setEpisodes(result.Items || [])) - .catch(() => setEpisodes([])); - // Sports row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsSports: true - }) - .then(result => setSports(result.Items || [])) - .catch(() => setSports([])); - // Kids row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsKids: true - }) - .then(result => setKids(result.Items || [])) - .catch(() => setKids([])); - // News row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsNews: true - }) - .then(result => setNews(result.Items || [])) - .catch(() => setNews([])); - // Programs row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: false, - IsSeries: false, - IsSports: false, - IsKids: false, - IsNews: false - }) - .then(result => setPrograms(result.Items || [])) - .catch(() => setPrograms([])); - // Channels row - fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) - .then(result => setChannels(result.Items || [])) - .catch(() => setChannels([])); - }, [collectionType, debouncedQuery, getDefaultParameters, parentId, serverId]); - - return ( -
- - - - - - - -
- ); -}; - -export default LiveTVSearchResults; diff --git a/src/components/search/SearchFields.tsx b/src/components/search/SearchFields.tsx index d2be6c26b3..865e8d7520 100644 --- a/src/components/search/SearchFields.tsx +++ b/src/components/search/SearchFields.tsx @@ -1,25 +1,22 @@ import React, { type ChangeEvent, type FC, useCallback, useRef } from 'react'; - import AlphaPicker from '../alphaPicker/AlphaPickerComponent'; import Input from 'elements/emby-input/Input'; import globalize from '../../scripts/globalize'; import layoutManager from '../layoutManager'; import browser from '../../scripts/browser'; - import 'material-design-icons-iconfont'; - import '../../styles/flexstyles.scss'; import './searchfields.scss'; -type SearchFieldsProps = { +interface SearchFieldsProps { query: string, onSearch?: (query: string) => void -}; +} const SearchFields: FC = ({ onSearch = () => { /* no-op */ }, query -}: SearchFieldsProps) => { +}) => { const inputRef = useRef(null); const onAlphaPicked = useCallback((e: Event) => { diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index cf36d13920..b350b0f1a1 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,388 +1,58 @@ -import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import type { ApiClient } from 'jellyfin-apiclient'; -import classNames from 'classnames'; -import React, { type FC, useCallback, useEffect, useState } from 'react'; -import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; -import { useDebounceValue } from 'usehooks-ts'; - +import React, { type FC } from 'react'; +import { Section, useSearchItems } from 'hooks/searchHook'; import globalize from '../../scripts/globalize'; -import ServerConnections from '../ServerConnections'; -import SearchResultsRow from './SearchResultsRow'; import Loading from '../loading/LoadingComponent'; +import SearchResultsRow from './SearchResultsRow'; +import { CardShape } from 'utils/card'; -interface ParametersOptions { - ParentId?: string | null; - searchTerm?: string; - Limit?: number; - Fields?: string; - Recursive?: boolean; - EnableTotalRecordCount?: boolean; - ImageTypeLimit?: number; - IncludePeople?: boolean; - IncludeMedia?: boolean; - IncludeGenres?: boolean; - IncludeStudios?: boolean; - IncludeArtists?: boolean; - IsMissing?: boolean; - IncludeItemTypes?: BaseItemKind; - MediaTypes?: string; - ExcludeItemTypes?: string; +interface SearchResultsProps { + parentId?: string; + collectionType?: string; + query?: string; } -type SearchResultsProps = { - serverId?: string; - parentId?: string | null; - collectionType?: string | null; - query?: string; -}; - -const ensureNonNullItems = (result: BaseItemDtoQueryResult) => ({ - ...result, - Items: result.Items || [] -}); - -const isMovies = (collectionType: string) => collectionType === CollectionType.Movies; - -const isMusic = (collectionType: string) => collectionType === CollectionType.Music; - -const isTVShows = (collectionType: string) => collectionType === CollectionType.Tvshows; - /* - * React component to display search result rows for global search and non-live tv library search + * React component to display search result rows for global search and library view search */ -const SearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { - const [ movies, setMovies ] = useState([]); - const [ shows, setShows ] = useState([]); - const [ episodes, setEpisodes ] = useState([]); - const [ videos, setVideos ] = useState([]); - const [ programs, setPrograms ] = useState([]); - const [ channels, setChannels ] = useState([]); - const [ playlists, setPlaylists ] = useState([]); - const [ artists, setArtists ] = useState([]); - const [ albums, setAlbums ] = useState([]); - const [ songs, setSongs ] = useState([]); - const [ photoAlbums, setPhotoAlbums ] = useState([]); - const [ photos, setPhotos ] = useState([]); - const [ audioBooks, setAudioBooks ] = useState([]); - const [ books, setBooks ] = useState([]); - const [ people, setPeople ] = useState([]); - const [ collections, setCollections ] = useState([]); - const [ isLoading, setIsLoading ] = useState(false); - const [ debouncedQuery ] = useDebounceValue(query, 500); +const SearchResults: FC = ({ + parentId, + collectionType, + query +}) => { + const { isLoading, data } = useSearchItems(parentId, collectionType, query); - const getDefaultParameters = useCallback(() => ({ - ParentId: parentId, - searchTerm: debouncedQuery, - Limit: 100, - Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', - Recursive: true, - EnableTotalRecordCount: false, - ImageTypeLimit: 1, - IncludePeople: false, - IncludeMedia: false, - IncludeGenres: false, - IncludeStudios: false, - IncludeArtists: false - }), [ parentId, debouncedQuery ]); + if (isLoading) return ; - const fetchArtists = useCallback((apiClient: ApiClient, params: ParametersOptions = {}) => ( - apiClient?.getArtists( - apiClient.getCurrentUserId(), - { - ...getDefaultParameters(), - IncludeArtists: true, - ...params - } - ).then(ensureNonNullItems) - ), [getDefaultParameters]); + if (!data?.length) { + return ( +
+ {globalize.translate('SearchResultsEmpty', query)} +
+ ); + } - const fetchItems = useCallback(async (apiClient?: ApiClient, params: ParametersOptions = {}) => { - if (!apiClient) { - console.error('[SearchResults] no apiClient; unable to fetch items'); - return { - Items: [] - }; - } - - const options: ParametersOptions = { - ...getDefaultParameters(), - IncludeMedia: true, - ...params - }; - - if (params.IncludeItemTypes === BaseItemKind.Episode) { - const user = await apiClient.getCurrentUser(); - if (!user?.Configuration?.DisplayMissingEpisodes) { - options.IsMissing = false; - } - } - - return apiClient.getItems( - apiClient.getCurrentUserId(), - options - ).then(ensureNonNullItems); - }, [getDefaultParameters]); - - const fetchPeople = useCallback((apiClient: ApiClient, params: ParametersOptions = {}) => ( - apiClient?.getPeople( - apiClient.getCurrentUserId(), - { - ...getDefaultParameters(), - IncludePeople: true, - ...params - } - ).then(ensureNonNullItems) - ), [getDefaultParameters]); - - useEffect(() => { - if (query) setIsLoading(true); - }, [ query ]); - - useEffect(() => { - // Reset state - setMovies([]); - setShows([]); - setEpisodes([]); - setVideos([]); - setPrograms([]); - setChannels([]); - setPlaylists([]); - setArtists([]); - setAlbums([]); - setSongs([]); - setPhotoAlbums([]); - setPhotos([]); - setAudioBooks([]); - setBooks([]); - setPeople([]); - setCollections([]); - - if (!debouncedQuery) { - setIsLoading(false); - return; - } - - const apiClient = ServerConnections.getApiClient(serverId); - const fetchPromises = []; - - // Movie libraries - if (!collectionType || isMovies(collectionType)) { - fetchPromises.push( - // Movies row - fetchItems(apiClient, { IncludeItemTypes: 'Movie' }) - .then(result => setMovies(result.Items)) - .catch(() => setMovies([])) - ); - } - - // TV Show libraries - if (!collectionType || isTVShows(collectionType)) { - fetchPromises.push( - // Shows row - fetchItems(apiClient, { IncludeItemTypes: 'Series' }) - .then(result => setShows(result.Items)) - .catch(() => setShows([])), - // Episodes row - fetchItems(apiClient, { IncludeItemTypes: 'Episode' }) - .then(result => setEpisodes(result.Items)) - .catch(() => setEpisodes([])) - ); - } - - // People are included for Movies and TV Shows - if (!collectionType || isMovies(collectionType) || isTVShows(collectionType)) { - fetchPromises.push( - // People row - fetchPeople(apiClient) - .then(result => setPeople(result.Items)) - .catch(() => setPeople([])) - ); - } - - // Music libraries - if (!collectionType || isMusic(collectionType)) { - fetchPromises.push( - // Playlists row - fetchItems(apiClient, { IncludeItemTypes: 'Playlist' }) - .then(results => setPlaylists(results.Items)) - .catch(() => setPlaylists([])), - // Artists row - fetchArtists(apiClient) - .then(result => setArtists(result.Items)) - .catch(() => setArtists([])), - // Albums row - fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' }) - .then(result => setAlbums(result.Items)) - .catch(() => setAlbums([])), - // Songs row - fetchItems(apiClient, { IncludeItemTypes: 'Audio' }) - .then(result => setSongs(result.Items)) - .catch(() => setSongs([])) - ); - } - - // Other libraries do not support in-library search currently - if (!collectionType) { - fetchPromises.push( - // Videos row - fetchItems(apiClient, { - MediaTypes: 'Video', - ExcludeItemTypes: 'Movie,Episode,TvChannel' - }) - .then(result => setVideos(result.Items)) - .catch(() => setVideos([])), - // Programs row - fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' }) - .then(result => setPrograms(result.Items)) - .catch(() => setPrograms([])), - // Channels row - fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) - .then(result => setChannels(result.Items)) - .catch(() => setChannels([])), - // Photo Albums row - fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' }) - .then(result => setPhotoAlbums(result.Items)) - .catch(() => setPhotoAlbums([])), - // Photos row - fetchItems(apiClient, { IncludeItemTypes: 'Photo' }) - .then(result => setPhotos(result.Items)) - .catch(() => setPhotos([])), - // Audio Books row - fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' }) - .then(result => setAudioBooks(result.Items)) - .catch(() => setAudioBooks([])), - // Books row - fetchItems(apiClient, { IncludeItemTypes: 'Book' }) - .then(result => setBooks(result.Items)) - .catch(() => setBooks([])), - // Collections row - fetchItems(apiClient, { IncludeItemTypes: 'BoxSet' }) - .then(result => setCollections(result.Items)) - .catch(() => setCollections([])) - ); - } - Promise.all(fetchPromises) - .then(() => { - setIsLoading(false); // Set loading to false when all fetch calls are done - }) - .catch((error) => { - console.error('An error occurred while fetching data:', error); - setIsLoading(false); // Set loading to false even if an error occurs - }); - }, [collectionType, fetchArtists, fetchItems, fetchPeople, debouncedQuery, serverId]); - - const allEmpty = [movies, shows, episodes, videos, programs, channels, playlists, artists, albums, songs, photoAlbums, photos, audioBooks, books, people, collections].every(arr => arr.length === 0); + const renderSection = (section: Section, index: number) => { + return ( + + ); + }; return ( -
- {isLoading ? ( - - ) : ( - <> - - - - - - - - - - - - - - - - - - {allEmpty && debouncedQuery && !isLoading && ( -
- {globalize.translate('SearchResultsEmpty', debouncedQuery)} -
- )} - - )} - +
+ {data.map((section, index) => renderSection(section, index))}
); }; diff --git a/src/components/search/SearchResultsRow.tsx b/src/components/search/SearchResultsRow.tsx index 0e5c1a50a4..4fad2a0966 100644 --- a/src/components/search/SearchResultsRow.tsx +++ b/src/components/search/SearchResultsRow.tsx @@ -1,8 +1,8 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; -import React, { FunctionComponent, useEffect, useRef } from 'react'; +import React, { type FC, useEffect, useRef } from 'react'; import cardBuilder from '../cardbuilder/cardBuilder'; - +import type { CardOptions } from 'types/cardOptions'; import '../../elements/emby-scroller/emby-scroller'; import '../../elements/emby-itemscontainer/emby-itemscontainer'; @@ -16,46 +16,18 @@ const createScroller = ({ title = '' }) => ({
` }); -type CardOptions = { - itemsContainer?: HTMLElement, - parentContainer?: HTMLElement, - allowBottomPadding?: boolean, - centerText?: boolean, - coverImage?: boolean, - inheritThumb?: boolean, - overlayMoreButton?: boolean, - overlayText?: boolean, - preferThumb?: boolean, - scalable?: boolean, - shape?: string, - showParentTitle?: boolean, - showParentTitleOrTitle?: boolean, - showAirTime?: boolean, - showAirDateTime?: boolean, - showChannelName?: boolean, - showTitle?: boolean, - showYear?: boolean -}; - -type SearchResultsRowProps = { +interface SearchResultsRowProps { title?: string; items?: BaseItemDto[]; cardOptions?: CardOptions; -}; +} -const SearchResultsRow: FunctionComponent = ({ title, items = [], cardOptions = {} }: SearchResultsRowProps) => { +const SearchResultsRow: FC = ({ title, items = [], cardOptions = {} }) => { const element = useRef(null); useEffect(() => { cardBuilder.buildCards(items, { itemsContainer: element.current?.querySelector('.itemsContainer'), - parentContainer: element.current, - shape: 'autooverflow', - scalable: true, - showTitle: true, - overlayText: false, - centerText: true, - allowBottomPadding: false, ...cardOptions }); }, [cardOptions, items]); diff --git a/src/components/search/SearchSuggestions.tsx b/src/components/search/SearchSuggestions.tsx index 1c902ba7b7..b9ebae523a 100644 --- a/src/components/search/SearchSuggestions.tsx +++ b/src/components/search/SearchSuggestions.tsx @@ -1,57 +1,19 @@ -import type { BaseItemDto } 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 { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; -import escapeHtml from 'escape-html'; -import React, { FunctionComponent, useEffect, useState } from 'react'; - +import React, { type FC } from 'react'; +import { useSearchSuggestions } from 'hooks/searchHook'; +import Loading from 'components/loading/LoadingComponent'; import { appRouter } from '../router/appRouter'; -import { useApi } from '../../hooks/useApi'; import globalize from '../../scripts/globalize'; - +import LinkButton from 'elements/emby-button/LinkButton'; import '../../elements/emby-button/emby-button'; -// 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 createSuggestionLink = ({ name, href }: { name: string, href: string }) => ({ - __html: `${escapeHtml(name)}` -}); +interface SearchSuggestionsProps { + parentId?: string; +} -type SearchSuggestionsProps = { - parentId?: string | null; -}; +const SearchSuggestions: FC = ({ parentId }) => { + const { isLoading, data: suggestions } = useSearchSuggestions(parentId); -const SearchSuggestions: FunctionComponent = ({ parentId }: SearchSuggestionsProps) => { - const [ suggestions, setSuggestions ] = useState([]); - const { api, user } = useApi(); - - useEffect(() => { - if (api && user?.Id) { - getItemsApi(api) - .getItems({ - userId: user.Id, - sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random], - includeItemTypes: [BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicArtist], - limit: 20, - recursive: true, - imageTypeLimit: 0, - enableImages: false, - parentId: parentId || undefined, - enableTotalRecordCount: false - }) - .then(result => setSuggestions(result.data.Items || [])) - .catch(err => { - console.error('[SearchSuggestions] failed to fetch search suggestions', err); - setSuggestions([]); - }); - } - }, [ api, parentId, user ]); + if (isLoading) return ; return (
= ({ parentId
- {suggestions.map(item => ( -
+ {suggestions?.map((item) => ( +
+ + {item.Name} + +
))}
diff --git a/src/hooks/searchHook/index.ts b/src/hooks/searchHook/index.ts new file mode 100644 index 0000000000..62ba9b422a --- /dev/null +++ b/src/hooks/searchHook/index.ts @@ -0,0 +1,2 @@ +export * from './useSearchItems'; +export * from './useSearchSuggestions'; diff --git a/src/hooks/searchHook/useSearchItems.ts b/src/hooks/searchHook/useSearchItems.ts new file mode 100644 index 0000000000..e720c8ac17 --- /dev/null +++ b/src/hooks/searchHook/useSearchItems.ts @@ -0,0 +1,509 @@ +import type { AxiosRequestConfig } from 'axios'; +import type { Api } from '@jellyfin/sdk'; +import type { + ArtistsApiGetArtistsRequest, + BaseItemDto, + ItemsApiGetItemsRequest, + PersonsApiGetPersonsRequest +} from '@jellyfin/sdk/lib/generated-client'; +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; +import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields'; +import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type'; +import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; +import { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api'; +import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api'; +import { useQuery } from '@tanstack/react-query'; +import { useApi } from '../useApi'; +import type { CardOptions } from 'types/cardOptions'; +import { CardShape } from 'utils/card'; + +const QUERY_OPTIONS = { + limit: 100, + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.CanDelete, + ItemFields.MediaSourceCount + ], + enableTotalRecordCount: false, + imageTypeLimit: 1 +}; + +const fetchItemsByType = async ( + api: Api, + userId?: string, + params?: ItemsApiGetItemsRequest, + options?: AxiosRequestConfig +) => { + const response = await getItemsApi(api).getItems( + { + ...QUERY_OPTIONS, + userId: userId, + recursive: true, + ...params + }, + options + ); + return response.data; +}; + +const fetchPeople = async ( + api: Api, + userId: string, + params?: PersonsApiGetPersonsRequest, + options?: AxiosRequestConfig +) => { + const response = await getPersonsApi(api).getPersons( + { + ...QUERY_OPTIONS, + userId: userId, + ...params + }, + options + ); + return response.data; +}; + +const fetchArtists = async ( + api: Api, + userId: string, + params?: ArtistsApiGetArtistsRequest, + options?: AxiosRequestConfig +) => { + const response = await getArtistsApi(api).getArtists( + { + ...QUERY_OPTIONS, + userId: userId, + ...params + }, + options + ); + return response.data; +}; + +const isMovies = (collectionType: string) => + collectionType === CollectionType.Movies; + +const isMusic = (collectionType: string) => + collectionType === CollectionType.Music; + +const isTVShows = (collectionType: string) => + collectionType === CollectionType.Tvshows; + +const isLivetv = (collectionType: string) => + collectionType === CollectionType.Livetv; + +const LIVETV_CARD_OPTIONS = { + preferThumb: true, + inheritThumb: false, + showParentTitleOrTitle: true, + showTitle: false, + coverImage: true, + overlayMoreButton: true, + showAirTime: true, + showAirDateTime: true, + showChannelName: true +}; + +export interface Section { + title: string + items: BaseItemDto[]; + cardOptions?: CardOptions; +} + +export const useSearchItems = ( + parentId?: string, + collectionType?: string, + searchTerm?: string +) => { + const { api, user } = useApi(); + const userId = user?.Id; + + return useQuery({ + queryKey: ['SearchItems', { parentId, collectionType, searchTerm }], + queryFn: async ({ signal }) => { + if (!api) throw new Error('No API instance available'); + if (!userId) throw new Error('No User ID provided'); + + const sections: Section[] = []; + + const addSection = ( + title: string, + items: BaseItemDto[] | null | undefined, + cardOptions?: CardOptions + ) => { + if (items && items?.length > 0) { + sections.push({ title, items, cardOptions }); + } + }; + + // Livetv libraries + if (collectionType && isLivetv(collectionType)) { + // Movies row + const moviesData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isMovie: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Movies', moviesData.Items, { + ...LIVETV_CARD_OPTIONS, + shape: CardShape.PortraitOverflow + }); + + // Episodes row + const episodesData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isMovie: false, + isSeries: true, + isSports: false, + isKids: false, + isNews: false, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Episodes', episodesData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Sports row + const sportsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isSports: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Sports', sportsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Kids row + const kidsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isKids: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Kids', kidsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // News row + const newsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isNews: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection('News', newsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Programs row + const programsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isMovie: false, + isSeries: false, + isSports: false, + isKids: false, + isNews: false, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Programs', programsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Channels row + const channelsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.TvChannel], + searchTerm: searchTerm + }, + { signal } + ); + addSection('Channels', channelsData.Items); + } + + // Movie libraries + if (!collectionType || isMovies(collectionType)) { + // Movies row + const moviesData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.Movie], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Movies', moviesData.Items, { + showYear: true + }); + } + + // TV Show libraries + if (!collectionType || isTVShows(collectionType)) { + // Shows row + const showsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.Series], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Shows', showsData.Items, { + showYear: true + }); + + // Episodes row + const episodesData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.Episode], + parentId: parentId, + isMissing: user?.Configuration?.DisplayMissingEpisodes, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Episodes', episodesData.Items, { + coverImage: true, + showParentTitle: true + }); + } + + // People are included for Movies and TV Shows + if ( + !collectionType + || isMovies(collectionType) + || isTVShows(collectionType) + ) { + // People row + const peopleData = await fetchPeople( + api, + userId, + { + searchTerm: searchTerm + }, + { signal } + ); + addSection('People', peopleData.Items, { + coverImage: true + }); + } + + // Music libraries + if (!collectionType || isMusic(collectionType)) { + // Playlists row + const playlistsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.Playlist], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Playlists', playlistsData.Items); + + // Artists row + const artistsData = await fetchArtists( + api, + userId, + { + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Artists', artistsData.Items, { + coverImage: true + }); + + // Albums row + const albumsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.MusicAlbum], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Albums', albumsData.Items, { + showYear: true + }); + + // Songs row + const songsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.Audio], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Songs', songsData.Items, { + showParentTitle: true, + shape: CardShape.SquareOverflow + }); + } + + // Other libraries do not support in-library search currently + if (!collectionType) { + // Videos row + const videosData = await fetchItemsByType( + api, + userId, + { + mediaTypes: [MediaType.Video], + excludeItemTypes: [ + BaseItemKind.Movie, + BaseItemKind.Episode, + BaseItemKind.TvChannel + ], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + + addSection('HeaderVideos', videosData.Items, { + showParentTitle: true + }); + + // Programs row + const programsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Programs', programsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Channels row + const channelsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.TvChannel], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Channels', channelsData.Items); + + // Photo Albums row + const photoAlbumsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.PhotoAlbum], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('HeaderPhotoAlbums', photoAlbumsData.Items); + + // Photos row + const photosData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.Photo], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Photos', photosData.Items); + + // Audio Books row + const audioBooksData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.AudioBook], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('HeaderAudioBooks', audioBooksData.Items); + + // Books row + const booksData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.Book], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Books', booksData.Items); + + // Collections row + const collectionsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.BoxSet], + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + addSection('Collections', collectionsData.Items); + } + + return sections; + }, + enabled: !!api && !!userId + }); +}; diff --git a/src/hooks/searchHook/useSearchSuggestions.ts b/src/hooks/searchHook/useSearchSuggestions.ts new file mode 100644 index 0000000000..a9b0ce4767 --- /dev/null +++ b/src/hooks/searchHook/useSearchSuggestions.ts @@ -0,0 +1,49 @@ +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 '../useApi'; + +const fetchGetItems = async ( + api?: Api, + userId?: string, + parentId?: string, + options?: AxiosRequestConfig +) => { + if (!api) throw new Error('No API instance available'); + if (!userId) throw new Error('No User ID provided'); + + const response = await getItemsApi(api).getItems( + { + userId: userId, + sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random], + includeItemTypes: [ + BaseItemKind.Movie, + BaseItemKind.Series, + BaseItemKind.MusicArtist + ], + limit: 20, + recursive: true, + imageTypeLimit: 0, + enableImages: false, + parentId: 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 }), + enabled: !!api && !!userId + }); +};