Repository: jellyfin/jellyfin-web
Pull request #6593: Search performance enhancements
Author: login: viown
Labels: name: feature
description: New feature or request
Created: 2025-03-04T14:04:31Z | Merged: 2025-03-26T22:54:10Z

**Changes**
- Reduce number of search requests per query from **22** to **5** for improved performance.
- Trim search query.
- Add a "Retry with a global search" link button when no results are found and the user is searching under a specific collection type.
- Fix bug with initial keystroke resulting in empty search

**Issues**
Fixes https://github.com/jellyfin/jellyfin-web/issues/6316
Fixes https://github.com/jellyfin/jellyfin-web/issues/6605



Diff:
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 000000000000..ec52347dbf2b
--- /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 000000000000..4be8c70b572c
--- /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 000000000000..e4fef3386e66
--- /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 000000000000..3dd1a74a59b7
--- /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 000000000000..23d91381b5b9
--- /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 000000000000..b528290d8725
--- /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 a9b0ce476774..af079fab1166 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 000000000000..06eaaf25e70f
--- /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 b1cea800c525..705144aff57a 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 3c7dec337634..47ce8f030cec 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<SearchResultsProps> = ({
     collectionType,
     query
 }) => {
-    const { isLoading, data } = useSearchItems(parentId, collectionType, query);
+    const { data, isPending } = useSearchItems(parentId, collectionType, query?.trim());
 
-    if (isLoading) return <Loading />;
+    if (isPending) return <Loading />;
 
     if (!data?.length) {
         return (
             <div className='noItemsMessage centerMessage'>
                 {globalize.translate('SearchResultsEmpty', query)}
+                {collectionType && (
+                    <div>
+                        <Link
+                            className='emby-button'
+                            to={`/search.html?query=${encodeURIComponent(query || '')}`}
+                        >{globalize.translate('RetryWithGlobalSearch')}</Link>
+                    </div>
+                )}
             </div>
         );
     }
@@ -51,7 +62,7 @@ const SearchResults: FC<SearchResultsProps> = ({
     };
 
     return (
-        <div className={'searchResults, padded-top, padded-bottom-page'}>
+        <div className={'searchResults padded-top padded-bottom-page'}>
             {data.map((section, index) => renderSection(section, index))}
         </div>
     );
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 4fad2a096664..be74c233c965 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 809ad294448f..a63514d157bf 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<SearchSuggestionsProps> = ({ parentId }) => {
-    const { isLoading, data: suggestions } = useSearchSuggestions(parentId || undefined);
+    const { data: suggestions, isPending } = useSearchSuggestions(parentId || undefined);
 
-    if (isLoading) return <Loading />;
+    if (isPending) return <Loading />;
 
     return (
         <div
diff --git a/src/components/search/searchfields.scss b/src/apps/stable/features/search/components/searchfields.scss
similarity index 100%
rename from src/components/search/searchfields.scss
rename to src/apps/stable/features/search/components/searchfields.scss
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 000000000000..5b654436d71c
--- /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 000000000000..0cc88a6fe441
--- /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 000000000000..86ddb6733fa9
--- /dev/null
+++ b/src/apps/stable/features/search/constants/sectionSortOrder.ts
@@ -0,0 +1,18 @@
+export const SEARCH_SECTIONS_SORT_ORDER = [
+    'Movies',
+    'Shows',
+    'Episodes',
+    'People',
+    'Playlists',
+    'Artists',
+    'Albums',
+    'Songs',
+    'HeaderVideos',
+    'Programs',
+    'Channels',
+    'HeaderPhotoAlbums',
+    'Photos',
+    'HeaderAudioBooks',
+    'Books',
+    'Collections'
+];
diff --git a/src/apps/stable/features/search/types.ts b/src/apps/stable/features/search/types.ts
new file mode 100644
index 000000000000..db2aea2cec57
--- /dev/null
+++ b/src/apps/stable/features/search/types.ts
@@ -0,0 +1,8 @@
+import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
+import { CardOptions } from 'types/cardOptions';
+
+export interface Section {
+    title: string
+    items: BaseItemDto[];
+    cardOptions?: CardOptions;
+};
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 000000000000..441b0a531db4
--- /dev/null
+++ b/src/apps/stable/features/search/utils/search.ts
@@ -0,0 +1,141 @@
+import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
+import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
+import { CardShape } from 'utils/card';
+import { Section } from '../types';
+import { CardOptions } from 'types/cardOptions';
+import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
+import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
+import { SEARCH_SECTIONS_SORT_ORDER } from '../constants/sectionSortOrder';
+
+export const isMovies = (collectionType: string) =>
+    collectionType === CollectionType.Movies;
+
+export const isTVShows = (collectionType: string) =>
+    collectionType === CollectionType.Tvshows;
+
+export const isMusic = (collectionType: string) =>
+    collectionType === CollectionType.Music;
+
+export const isLivetv = (collectionType: string) =>
+    collectionType === CollectionType.Livetv;
+
+export function addSection(
+    sections: Section[],
+    title: string,
+    items: BaseItemDto[] | null | undefined,
+    cardOptions?: CardOptions
+) {
+    if (items && items?.length > 0) {
+        sections.push({ title, items, cardOptions });
+    }
+}
+
+export function sortSections(sections: Section[]) {
+    return sections.sort((a, b) => {
+        const indexA = SEARCH_SECTIONS_SORT_ORDER.indexOf(a.title);
+        const indexB = SEARCH_SECTIONS_SORT_ORDER.indexOf(b.title);
+
+        if (indexA > indexB) {
+            return 1;
+        } else if (indexA < indexB) {
+            return -1;
+        } else {
+            return 0;
+        }
+    });
+}
+
+export function getCardOptionsFromType(type: BaseItemKind) {
+    switch (type) {
+        case BaseItemKind.Movie:
+        case BaseItemKind.Series:
+        case BaseItemKind.MusicAlbum:
+            return {
+                showYear: true
+            };
+        case BaseItemKind.Episode:
+            return {
+                coverImage: true,
+                showParentTitle: true
+            };
+        case BaseItemKind.MusicArtist:
+            return {
+                coverImage: true
+            };
+        case BaseItemKind.Audio:
+            return {
+                showParentTitle: true,
+                shape: CardShape.SquareOverflow
+            };
+        case BaseItemKind.LiveTvProgram:
+            return LIVETV_CARD_OPTIONS;
+        default:
+            return {};
+    }
+}
+
+export function getTitleFromType(type: BaseItemKind) {
+    switch (type) {
+        case BaseItemKind.Movie:
+            return 'Movies';
+        case BaseItemKind.Series:
+            return 'Shows';
+        case BaseItemKind.Episode:
+            return 'Episodes';
+        case BaseItemKind.Playlist:
+            return 'Playlists';
+        case BaseItemKind.MusicAlbum:
+            return 'Albums';
+        case BaseItemKind.Audio:
+            return 'Songs';
+        case BaseItemKind.LiveTvProgram:
+            return 'Programs';
+        case BaseItemKind.TvChannel:
+            return 'Channels';
+        case BaseItemKind.PhotoAlbum:
+            return 'HeaderPhotoAlbums';
+        case BaseItemKind.Photo:
+            return 'Photos';
+        case BaseItemKind.AudioBook:
+            return 'HeaderAudioBooks';
+        case BaseItemKind.Book:
+            return 'Books';
+        case BaseItemKind.BoxSet:
+            return 'Collections';
+        default:
+            return '';
+    }
+}
+
+export function getItemTypesFromCollectionType(collectionType: CollectionType | undefined) {
+    switch (collectionType) {
+        case CollectionType.Movies:
+            return [ BaseItemKind.Movie ];
+        case CollectionType.Tvshows:
+            return [
+                BaseItemKind.Series,
+                BaseItemKind.Episode
+            ];
+        case CollectionType.Music:
+            return [
+                BaseItemKind.Playlist,
+                BaseItemKind.MusicAlbum,
+                BaseItemKind.Audio
+            ];
+        default:
+            return [
+                BaseItemKind.Movie,
+                BaseItemKind.Series,
+                BaseItemKind.Episode,
+                BaseItemKind.Playlist,
+                BaseItemKind.MusicAlbum,
+                BaseItemKind.Audio,
+                BaseItemKind.TvChannel,
+                BaseItemKind.PhotoAlbum,
+                BaseItemKind.Photo,
+                BaseItemKind.AudioBook,
+                BaseItemKind.Book,
+                BaseItemKind.BoxSet
+            ];
+    }
+}
diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx
index 290ea604065c..f06ce927d8e7 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'
         >
             <SearchFields query={query} onSearch={setQuery} />
-            {!query ? (
+            {!debouncedQuery ? (
                 <SearchSuggestions
                     parentId={parentIdQuery}
                 />
diff --git a/src/hooks/searchHook/index.ts b/src/hooks/searchHook/index.ts
deleted file mode 100644
index 62ba9b422a73..000000000000
--- 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 e720c8ac1718..000000000000
--- 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 0204b9817d06..ce4ede183a90 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",


Reviews:
user: login: thornbill
body: Search results look good in my testing and the speed improvement is noticeable! :rocket: 

I just have some minor code style suggestions...
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-26T05:46:54Z
user: login: thornbill
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T22:54:00Z

Review comments:
diff_hunk: @@ -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,
path: src/apps/stable/features/search/api/fetchItemsByType.ts
user: login: thornbill
body: ```suggestion
            userId,
```
created_at: 2025-03-26T05:05:25Z
author_association: MEMBER
diff_hunk: @@ -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: userId,
path: src/apps/stable/features/search/api/useArtistsSearch.ts
user: login: thornbill
body: ```suggestion
            userId,
```
created_at: 2025-03-26T05:05:56Z
author_association: MEMBER
diff_hunk: @@ -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: 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: parentId,
+                searchTerm: searchTerm
path: src/apps/stable/features/search/api/useArtistsSearch.ts
user: login: thornbill
body: ```suggestion
                parentId,
                searchTerm
```
created_at: 2025-03-26T05:07:16Z
author_association: MEMBER
diff_hunk: @@ -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: searchTerm
path: src/apps/stable/features/search/api/useLiveTvSearch.ts
user: login: thornbill
body: ```suggestion
            searchTerm
```
created_at: 2025-03-26T05:08:16Z
author_association: MEMBER
diff_hunk: @@ -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: 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: searchTerm
path: src/apps/stable/features/search/api/useLiveTvSearch.ts
user: login: thornbill
body: ```suggestion
            searchTerm
```
created_at: 2025-03-26T05:08:57Z
author_association: MEMBER
diff_hunk: @@ -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: 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: 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: searchTerm
path: src/apps/stable/features/search/api/useLiveTvSearch.ts
user: login: thornbill
body: ```suggestion
            searchTerm
```
created_at: 2025-03-26T05:10:00Z
author_association: MEMBER
diff_hunk: @@ -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: 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: 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: 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: searchTerm
path: src/apps/stable/features/search/api/useLiveTvSearch.ts
user: login: thornbill
body: ```suggestion
            searchTerm
```
created_at: 2025-03-26T05:10:33Z
author_association: MEMBER
diff_hunk: @@ -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: 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: 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: 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: 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: searchTerm
path: src/apps/stable/features/search/api/useLiveTvSearch.ts
user: login: thornbill
body: ```suggestion
            searchTerm
```
created_at: 2025-03-26T05:10:55Z
author_association: MEMBER
diff_hunk: @@ -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: 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: 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: 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: 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: 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: searchTerm
path: src/apps/stable/features/search/api/useLiveTvSearch.ts
user: login: thornbill
body: ```suggestion
            searchTerm
```
created_at: 2025-03-26T05:11:30Z
author_association: MEMBER
diff_hunk: @@ -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: 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: 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: 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: 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: 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: searchTerm
+        },
+        { signal }
+    ).then(programsData => {
+        addSection(sections, 'Programs', programsData.Items, {
+            ...LIVETV_CARD_OPTIONS
+        });
+    });
+
+    // Channels row
+    const channels = fetchItemsByType(
+        api,
+        userId,
+        {
+            includeItemTypes: [ BaseItemKind.TvChannel ],
+            searchTerm: searchTerm
path: src/apps/stable/features/search/api/useLiveTvSearch.ts
user: login: thornbill
body: ```suggestion
            searchTerm
```
created_at: 2025-03-26T05:11:54Z
author_association: MEMBER
diff_hunk: @@ -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: userId,
path: src/apps/stable/features/search/api/usePeopleSearch.ts
user: login: thornbill
body: ```suggestion
            userId,
```
created_at: 2025-03-26T05:12:47Z
author_association: MEMBER
diff_hunk: @@ -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: parentId,
+                searchTerm: searchTerm
path: src/apps/stable/features/search/api/useProgramsSearch.ts
user: login: thornbill
body: ```suggestion
                parentId,
                searchTerm
```
created_at: 2025-03-26T05:17:22Z
author_association: MEMBER
diff_hunk: @@ -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: 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: searchTerm
+            },
path: src/apps/stable/features/search/api/usePeopleSearch.ts
user: login: thornbill
body: ```suggestion
            { searchTerm },
```
created_at: 2025-03-26T05:44:40Z
author_association: MEMBER
diff_hunk: @@ -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: parentId,
+                    searchTerm: searchTerm,
path: src/apps/stable/features/search/api/useSearchItems.ts
user: login: thornbill
body: ```suggestion
                    parentId,
                    searchTerm,
```
created_at: 2025-03-26T05:45:08Z
author_association: MEMBER
diff_hunk: @@ -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: 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: parentId,
+                searchTerm: searchTerm
path: src/apps/stable/features/search/api/useVideoSearch.ts
user: login: thornbill
body: ```suggestion
                parentId,
                searchTerm
```
created_at: 2025-03-26T05:45:41Z
author_association: MEMBER

PR comments:
user: login: jellyfin-bot
created_at: 2025-03-04T14:04:45Z
body: ## Cloudflare Pages deployment

| **Latest commit**       	| <code>34f5be9</code>     |
|-------------------------	|:----------------------------:	|
| **Status**              	| ✅ Deployed!                |
| **Preview URL**         	| https://a4d79413.jellyfin-web.pages.dev                  |
| **Type**         	        | 🔀 Preview              |

**[View build logs](https://github.com/jellyfin/jellyfin-web/actions/runs/14091504753)**
<!-- thollander/actions-comment-pull-request "CFPages-deployment" -->
author_association: COLLABORATOR
user: login: jhparker88
created_at: 2025-03-04T16:52:00Z
body: It generally happens that when I search for something without realizing that I'm in for a specific `collectionType` I wouldn't find what I want and only after looking at the address URL I would see that the search is limited to a type. Maybe it would be possible to indicate the collection type in the Title (red box).

No search found (specific type)

![Screenshot 2025-03-05 013811_p](https://github.com/user-attachments/assets/e3106ae7-6125-45f5-82c3-ff3e5ed76118)

Search found (no specific type)

![Screenshot 2025-03-05 013939_p](https://github.com/user-attachments/assets/c99b3673-aa65-415f-a3a8-69bb6dad20e9)

author_association: NONE
user: login: viown
created_at: 2025-03-04T17:06:41Z
body: Thanks for your feedback, @jhparker88. This is indeed another issue I'm trying to tackle with this PR, currently I've added a link button to reset the collection type and retry the search when no results are found which should help mitigate this issue to some extent.
author_association: MEMBER
user: login: jhparker88
created_at: 2025-03-04T17:42:26Z
body: > currently I've added a link button to reset the collection type and retry the search when no results are found which should help mitigate this issue to some extent.

Not to make this overly more complicated then it already is but on a modern search interface one would have a dropdown with the available types (or none to indicate a global search) which could be defaulted to the type from a session one is coming from.

![Screenshot 2025-03-05 023235](https://github.com/user-attachments/assets/3fe3ce64-2674-409c-831a-f303feb97f09)

PS: I'm digressing here but maybe make the search interface easily extendible so in future one can add some other search fields (year etc.) or a sort drop down because searching for some text plus a limiting factor such as a year is currently not possible.
author_association: NONE
user: login: thornbill
created_at: 2025-03-22T07:55:55Z
body: I haven't had a chance to do a thorough review of the code yet, but I did some testing of the global search and it seems to be working really well! Search for `LiveTvProgram` type items seems pretty broken though...

- Results are currently not displayed because they have the `Program` type. (However using `Program` in the query instead of `LiveTvProgram` returns no results.)
- If `Movie`, `Series`, or `Episode` types are included only one `Program` type item is returned.

These seem like potential server issues, but maybe we can move `LiveTvProgram` search to a separate query to workaround them for now...
author_association: MEMBER
user: login: viown
created_at: 2025-03-24T17:26:56Z
body: Thanks for testing, I've separated the query but I don't have LiveTV to actually test it.

Also, possible optimization idea for the future: only perform queries for item types which have a corresponding user view available. Refactor of the search in this PR should make this trivial.
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-03-26T19:09:07Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-web&pullRequest=6593) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=jellyfin_jellyfin-web&pullRequest=6593&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=jellyfin_jellyfin-web&pullRequest=6593&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=jellyfin_jellyfin-web&pullRequest=6593&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=jellyfin_jellyfin-web&pullRequest=6593&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=jellyfin_jellyfin-web&pullRequest=6593&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-web&pullRequest=6593)


author_association: NONE