diff --git a/src/apps/stable/features/search/api/fetchItemsByType.ts b/src/apps/stable/features/search/api/fetchItemsByType.ts new file mode 100644 index 0000000000..ec52347dbf --- /dev/null +++ b/src/apps/stable/features/search/api/fetchItemsByType.ts @@ -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; +}; diff --git a/src/apps/stable/features/search/api/useArtistsSearch.ts b/src/apps/stable/features/search/api/useArtistsSearch.ts new file mode 100644 index 0000000000..4be8c70b57 --- /dev/null +++ b/src/apps/stable/features/search/api/useArtistsSearch.ts @@ -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)) + }); +}; diff --git a/src/apps/stable/features/search/api/useLiveTvSearch.ts b/src/apps/stable/features/search/api/useLiveTvSearch.ts new file mode 100644 index 0000000000..e4fef3386e --- /dev/null +++ b/src/apps/stable/features/search/api/useLiveTvSearch.ts @@ -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) + }); +}; diff --git a/src/apps/stable/features/search/api/usePeopleSearch.ts b/src/apps/stable/features/search/api/usePeopleSearch.ts new file mode 100644 index 0000000000..3dd1a74a59 --- /dev/null +++ b/src/apps/stable/features/search/api/usePeopleSearch.ts @@ -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 + }); +}; diff --git a/src/apps/stable/features/search/api/useProgramsSearch.ts b/src/apps/stable/features/search/api/useProgramsSearch.ts new file mode 100644 index 0000000000..23d91381b5 --- /dev/null +++ b/src/apps/stable/features/search/api/useProgramsSearch.ts @@ -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 + }); +}; diff --git a/src/apps/stable/features/search/api/useSearchItems.ts b/src/apps/stable/features/search/api/useSearchItems.ts new file mode 100644 index 0000000000..b528290d87 --- /dev/null +++ b/src/apps/stable/features/search/api/useSearchItems.ts @@ -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 + ) + }); +}; diff --git a/src/hooks/searchHook/useSearchSuggestions.ts b/src/apps/stable/features/search/api/useSearchSuggestions.ts similarity index 80% rename from src/hooks/searchHook/useSearchSuggestions.ts rename to src/apps/stable/features/search/api/useSearchSuggestions.ts index a9b0ce4767..af079fab11 100644 --- a/src/hooks/searchHook/useSearchSuggestions.ts +++ b/src/apps/stable/features/search/api/useSearchSuggestions.ts @@ -4,20 +4,17 @@ 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'; +import { useApi } from 'hooks/useApi'; const fetchGetItems = async ( - api?: Api, - userId?: string, + 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, + userId, sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random], includeItemTypes: [ BaseItemKind.Movie, @@ -28,7 +25,7 @@ const fetchGetItems = async ( recursive: true, imageTypeLimit: 0, enableImages: false, - parentId: parentId, + parentId, enableTotalRecordCount: false }, options @@ -43,7 +40,8 @@ export const useSearchSuggestions = (parentId?: string) => { return useQuery({ queryKey: ['SearchSuggestions', { parentId }], queryFn: ({ signal }) => - fetchGetItems(api, userId, parentId, { signal }), + fetchGetItems(api!, userId!, parentId, { signal }), + refetchOnWindowFocus: false, enabled: !!api && !!userId }); }; diff --git a/src/apps/stable/features/search/api/useVideoSearch.ts b/src/apps/stable/features/search/api/useVideoSearch.ts new file mode 100644 index 0000000000..06eaaf25e7 --- /dev/null +++ b/src/apps/stable/features/search/api/useVideoSearch.ts @@ -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 + }); +}; diff --git a/src/components/search/SearchFields.tsx b/src/apps/stable/features/search/components/SearchFields.tsx similarity index 90% rename from src/components/search/SearchFields.tsx rename to src/apps/stable/features/search/components/SearchFields.tsx index b1cea800c5..705144aff5 100644 --- a/src/components/search/SearchFields.tsx +++ b/src/apps/stable/features/search/components/SearchFields.tsx @@ -1,11 +1,11 @@ import React, { type ChangeEvent, type FC, useCallback, useRef } from 'react'; -import AlphaPicker from '../alphaPicker/AlphaPickerComponent'; +import AlphaPicker from 'components/alphaPicker/AlphaPickerComponent'; import Input from 'elements/emby-input/Input'; -import globalize from '../../lib/globalize'; -import layoutManager from '../layoutManager'; -import browser from '../../scripts/browser'; +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 'styles/flexstyles.scss'; import './searchfields.scss'; interface SearchFieldsProps { diff --git a/src/components/search/SearchResults.tsx b/src/apps/stable/features/search/components/SearchResults.tsx similarity index 60% rename from src/components/search/SearchResults.tsx rename to src/apps/stable/features/search/components/SearchResults.tsx index 3c7dec3376..47ce8f030c 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/apps/stable/features/search/components/SearchResults.tsx @@ -1,13 +1,16 @@ import React, { type FC } from 'react'; -import { Section, useSearchItems } from 'hooks/searchHook'; -import globalize from '../../lib/globalize'; -import Loading from '../loading/LoadingComponent'; +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?: string; + collectionType?: CollectionType; query?: string; } @@ -19,14 +22,22 @@ const SearchResults: FC = ({ collectionType, query }) => { - const { isLoading, data } = useSearchItems(parentId, collectionType, query); + const { data, isPending } = useSearchItems(parentId, collectionType, query?.trim()); - if (isLoading) return ; + if (isPending) return ; if (!data?.length) { return (
{globalize.translate('SearchResultsEmpty', query)} + {collectionType && ( +
+ {globalize.translate('RetryWithGlobalSearch')} +
+ )}
); } @@ -51,7 +62,7 @@ const SearchResults: FC = ({ }; return ( -
+
{data.map((section, index) => renderSection(section, index))}
); diff --git a/src/components/search/SearchResultsRow.tsx b/src/apps/stable/features/search/components/SearchResultsRow.tsx similarity index 89% rename from src/components/search/SearchResultsRow.tsx rename to src/apps/stable/features/search/components/SearchResultsRow.tsx index 4fad2a0966..be74c233c9 100644 --- a/src/components/search/SearchResultsRow.tsx +++ b/src/apps/stable/features/search/components/SearchResultsRow.tsx @@ -1,10 +1,10 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; import React, { type FC, useEffect, useRef } from 'react'; -import cardBuilder from '../cardbuilder/cardBuilder'; +import cardBuilder from 'components/cardbuilder/cardBuilder'; import type { CardOptions } from 'types/cardOptions'; -import '../../elements/emby-scroller/emby-scroller'; -import '../../elements/emby-itemscontainer/emby-itemscontainer'; +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 diff --git a/src/components/search/SearchSuggestions.tsx b/src/apps/stable/features/search/components/SearchSuggestions.tsx similarity index 78% rename from src/components/search/SearchSuggestions.tsx rename to src/apps/stable/features/search/components/SearchSuggestions.tsx index 809ad29444..a63514d157 100644 --- a/src/components/search/SearchSuggestions.tsx +++ b/src/apps/stable/features/search/components/SearchSuggestions.tsx @@ -1,21 +1,21 @@ import React, { FunctionComponent } from 'react'; import Loading from 'components/loading/LoadingComponent'; -import { appRouter } from '../router/appRouter'; -import { useSearchSuggestions } from 'hooks/searchHook/useSearchSuggestions'; +import { appRouter } from 'components/router/appRouter'; +import { useSearchSuggestions } from '../api/useSearchSuggestions'; import globalize from 'lib/globalize'; -import LinkButton from '../../elements/emby-button/LinkButton'; +import LinkButton from 'elements/emby-button/LinkButton'; -import '../../elements/emby-button/emby-button'; +import 'elements/emby-button/emby-button'; type SearchSuggestionsProps = { parentId?: string | null; }; const SearchSuggestions: FunctionComponent = ({ parentId }) => { - const { isLoading, data: suggestions } = useSearchSuggestions(parentId || undefined); + const { data: suggestions, isPending } = useSearchSuggestions(parentId || undefined); - if (isLoading) return ; + if (isPending) return ; return (
+ 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 + ]; + } +} diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 290ea60406..f06ce927d8 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -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' > - {!query ? ( + {!debouncedQuery ? ( diff --git a/src/hooks/searchHook/index.ts b/src/hooks/searchHook/index.ts deleted file mode 100644 index 62ba9b422a..0000000000 --- a/src/hooks/searchHook/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './useSearchItems'; -export * from './useSearchSuggestions'; diff --git a/src/hooks/searchHook/useSearchItems.ts b/src/hooks/searchHook/useSearchItems.ts deleted file mode 100644 index e720c8ac17..0000000000 --- a/src/hooks/searchHook/useSearchItems.ts +++ /dev/null @@ -1,509 +0,0 @@ -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/strings/en-us.json b/src/strings/en-us.json index 0204b9817d..ce4ede183a 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1451,6 +1451,7 @@ "ReplaceExistingImages": "Replace existing images", "ReplaceTrickplayImages": "Replace existing trickplay images", "Retry": "Retry", + "RetryWithGlobalSearch": "Retry with a global search", "Reset": "Reset", "ResetPassword": "Reset Password", "ResolutionMatchSource": "Match Source",