From 5b81d4a2fc9edb658a548ed5fd5f6ea03d161d1b Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Mon, 3 Mar 2025 18:18:39 +0300 Subject: [PATCH] Combine search queries and make them synchronous --- .../features/search/api/fetchItemsByType.ts | 23 + .../features/search/api/useArtistsSearch.ts | 53 ++ .../features/search/api/useLiveTvSearch.ts | 143 +++++ .../features/search/api/usePeopleSearch.ts | 54 ++ .../features/search/api/useSearchItems.ts | 547 ++---------------- .../features/search/api/useVideoSearch.ts | 61 ++ .../search/components/SearchResults.tsx | 10 +- .../search/constants/liveTvCardOptions.ts | 11 + .../features/search/constants/queryOptions.ts | 12 + .../search/constants/sectionSortOrder.ts | 20 + src/apps/stable/features/search/types.ts | 10 + .../stable/features/search/utils/search.ts | 144 +++++ src/apps/stable/routes/search.tsx | 3 +- 13 files changed, 600 insertions(+), 491 deletions(-) create mode 100644 src/apps/stable/features/search/api/fetchItemsByType.ts create mode 100644 src/apps/stable/features/search/api/useArtistsSearch.ts create mode 100644 src/apps/stable/features/search/api/useLiveTvSearch.ts create mode 100644 src/apps/stable/features/search/api/usePeopleSearch.ts create mode 100644 src/apps/stable/features/search/api/useVideoSearch.ts create mode 100644 src/apps/stable/features/search/constants/liveTvCardOptions.ts create mode 100644 src/apps/stable/features/search/constants/queryOptions.ts create mode 100644 src/apps/stable/features/search/constants/sectionSortOrder.ts create mode 100644 src/apps/stable/features/search/types.ts create mode 100644 src/apps/stable/features/search/utils/search.ts 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..dfe5edaa16 --- /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: 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..6bcaaa2b9f --- /dev/null +++ b/src/apps/stable/features/search/api/useArtistsSearch.ts @@ -0,0 +1,53 @@ +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: 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: ['ArtistsSearch', collectionType, parentId, searchTerm], + queryFn: async ({ signal }) => { + const artistsData = await fetchArtists( + api!, + userId!, + { + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + + return artistsData; + }, + 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..6fc015f96e --- /dev/null +++ b/src/apps/stable/features/search/api/useLiveTvSearch.ts @@ -0,0 +1,143 @@ +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 = async (api: Api, userId: string | undefined, searchTerm: string | undefined, signal: AbortSignal) => { + const sections: Section[] = []; + + // Movies row + const moviesData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isMovie: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection(sections, '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(sections, 'Episodes', episodesData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Sports row + const sportsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isSports: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection(sections, 'Sports', sportsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Kids row + const kidsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isKids: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection(sections, 'Kids', kidsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // News row + const newsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.LiveTvProgram], + isNews: true, + searchTerm: searchTerm + }, + { signal } + ); + addSection(sections, '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(sections, 'Programs', programsData.Items, { + ...LIVETV_CARD_OPTIONS + }); + + // Channels row + const channelsData = await fetchItemsByType( + api, + userId, + { + includeItemTypes: [BaseItemKind.TvChannel], + searchTerm: searchTerm + }, + { signal } + ); + addSection(sections, 'Channels', channelsData.Items); + + return sections; +}; + +export const useLiveTvSearch = ( + parentId?: string, + collectionType?: CollectionType, + searchTerm?: string +) => { + const { api, user } = useApi(); + const userId = user?.Id; + + return useQuery({ + queryKey: ['LiveTv', collectionType, parentId, searchTerm], + queryFn: async ({ 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..d485e9362c --- /dev/null +++ b/src/apps/stable/features/search/api/usePeopleSearch.ts @@ -0,0 +1,54 @@ +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: 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: ['PeopleSearch', collectionType, parentId, searchTerm], + queryFn: async ({ signal }) => { + const peopleData = await fetchPeople( + api!, + userId!, + { + searchTerm: searchTerm + }, + { signal } + ); + + return peopleData; + }, + enabled: !!api && !!userId && isPeopleEnabled + }); +}; diff --git a/src/apps/stable/features/search/api/useSearchItems.ts b/src/apps/stable/features/search/api/useSearchItems.ts index d073fac379..0b0662b623 100644 --- a/src/apps/stable/features/search/api/useSearchItems.ts +++ b/src/apps/stable/features/search/api/useSearchItems.ts @@ -1,509 +1,84 @@ -import type { AxiosRequestConfig } from 'axios'; -import type { Api } from '@jellyfin/sdk'; import type { - ArtistsApiGetArtistsRequest, BaseItemDto, - ItemsApiGetItemsRequest, - PersonsApiGetPersonsRequest + BaseItemKind } 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 '../../../../../hooks/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; -} +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'; export const useSearchItems = ( parentId?: string, - collectionType?: 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: liveTvSections, isPending: isLiveTvPending } = useLiveTvSearch(parentId, collectionType, searchTerm); 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 isArtistsEnabled = !isArtistsPending || (collectionType && !isMusic(collectionType)); + const isPeopleEnabled = !isPeoplePending || (collectionType && !isMovies(collectionType) && !isTVShows(collectionType)); + const isVideosEnabled = !isVideosPending || collectionType; + const isLiveTvEnabled = !isLiveTvPending || !collectionType || !isLivetv(collectionType); + return useQuery({ + queryKey: ['SearchItems', collectionType, parentId, searchTerm], + queryFn: async ({ signal }) => { const sections: Section[] = []; - const addSection = ( - title: string, - items: BaseItemDto[] | null | undefined, - cardOptions?: CardOptions - ) => { - if (items && items?.length > 0) { - sections.push({ title, items, cardOptions }); + addSection(sections, 'Artists', artists?.Items, { + coverImage: true + }); + + addSection(sections, 'People', people?.Items, { + coverImage: true + }); + + addSection(sections, 'HeaderVideos', videos?.Items, { + showParentTitle: true + }); + + if (liveTvSections) { + sections.push(...liveTvSections); + } + + const itemTypes: BaseItemKind[] = getItemTypesFromCollectionType(collectionType); + + const searchData = await fetchItemsByType( + api!, + userId, + { + includeItemTypes: itemTypes, + parentId: parentId, + searchTerm: 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)); } - }; - - // 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; + return sortSections(sections); }, - enabled: !!api && !!userId + enabled: !!api && !!userId && !!isArtistsEnabled && !!isPeopleEnabled && !!isVideosEnabled && !!isLiveTvEnabled }); }; 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..a4b1a6be13 --- /dev/null +++ b/src/apps/stable/features/search/api/useVideoSearch.ts @@ -0,0 +1,61 @@ +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 fetchPeople = async ( + api: Api, + userId: string, + params?: ItemsApiGetItemsRequest, + options?: AxiosRequestConfig +) => { + const response = await getItemsApi(api).getItems( + { + ...QUERY_OPTIONS, + userId: 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: ['VideoSearch', collectionType, parentId, searchTerm], + queryFn: async ({ signal }) => { + const videosData = await fetchPeople( + api!, + userId!, + { + parentId: parentId, + searchTerm: searchTerm + }, + { signal } + ); + + return videosData; + }, + enabled: !!api && !!userId && !collectionType + }); +}; diff --git a/src/apps/stable/features/search/components/SearchResults.tsx b/src/apps/stable/features/search/components/SearchResults.tsx index 24e6a83e4f..97d23a9c4a 100644 --- a/src/apps/stable/features/search/components/SearchResults.tsx +++ b/src/apps/stable/features/search/components/SearchResults.tsx @@ -1,13 +1,15 @@ import React, { type FC } from 'react'; -import { Section, useSearchItems } from '../api/useSearchItems'; +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'; interface SearchResultsProps { parentId?: string; - collectionType?: string; + collectionType?: CollectionType; query?: string; } @@ -19,9 +21,9 @@ 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 ( diff --git a/src/apps/stable/features/search/constants/liveTvCardOptions.ts b/src/apps/stable/features/search/constants/liveTvCardOptions.ts new file mode 100644 index 0000000000..5b654436d7 --- /dev/null +++ b/src/apps/stable/features/search/constants/liveTvCardOptions.ts @@ -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 +}; diff --git a/src/apps/stable/features/search/constants/queryOptions.ts b/src/apps/stable/features/search/constants/queryOptions.ts new file mode 100644 index 0000000000..0cc88a6fe4 --- /dev/null +++ b/src/apps/stable/features/search/constants/queryOptions.ts @@ -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 +}; diff --git a/src/apps/stable/features/search/constants/sectionSortOrder.ts b/src/apps/stable/features/search/constants/sectionSortOrder.ts new file mode 100644 index 0000000000..bd5e53b81f --- /dev/null +++ b/src/apps/stable/features/search/constants/sectionSortOrder.ts @@ -0,0 +1,20 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'; + +export const SEARCH_SECTIONS_SORT_ORDER: BaseItemKind[] = [ + BaseItemKind.Movie, + BaseItemKind.Series, + BaseItemKind.Episode, + BaseItemKind.Person, + BaseItemKind.Playlist, + BaseItemKind.MusicArtist, + BaseItemKind.MusicAlbum, + BaseItemKind.Audio, + BaseItemKind.Video, + BaseItemKind.LiveTvProgram, + BaseItemKind.TvChannel, + BaseItemKind.PhotoAlbum, + BaseItemKind.Photo, + BaseItemKind.AudioBook, + BaseItemKind.Book, + BaseItemKind.BoxSet +]; diff --git a/src/apps/stable/features/search/types.ts b/src/apps/stable/features/search/types.ts new file mode 100644 index 0000000000..1544178bc6 --- /dev/null +++ b/src/apps/stable/features/search/types.ts @@ -0,0 +1,10 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'; +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[]; + sectionType: BaseItemKind; + cardOptions?: CardOptions; +}; diff --git a/src/apps/stable/features/search/utils/search.ts b/src/apps/stable/features/search/utils/search.ts new file mode 100644 index 0000000000..8ff0458a8a --- /dev/null +++ b/src/apps/stable/features/search/utils/search.ts @@ -0,0 +1,144 @@ +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 && items[0].Type) { + sections.push({ title, items, sectionType: items[0].Type, cardOptions }); + } +} + +export function sortSections(sections: Section[]) { + return sections.sort((a, b) => { + const indexA = SEARCH_SECTIONS_SORT_ORDER.indexOf(a.sectionType); + const indexB = SEARCH_SECTIONS_SORT_ORDER.indexOf(b.sectionType); + + 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 + ]; + case CollectionType.Livetv: + return []; + default: + return [ + BaseItemKind.Movie, + BaseItemKind.Series, + BaseItemKind.Episode, + BaseItemKind.Playlist, + BaseItemKind.MusicAlbum, + BaseItemKind.Audio, + BaseItemKind.LiveTvProgram, + 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 4cf0b29b72..140c88c69a 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -7,6 +7,7 @@ import Page from 'components/Page'; 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, '');