import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import AlbumCover from "@/components/posters/AlbumCover"; import MoviePoster from "@/components/posters/MoviePoster"; import SeriesPoster from "@/components/posters/SeriesPoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { BaseItemDto, BaseItemKind, } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; import { Href, router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { PropsWithChildren, useCallback, useEffect, useLayoutEffect, useMemo, useState, } from "react"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useDebounce } from "use-debounce"; import {useJellyseerr} from "@/hooks/useJellyseerr"; import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; import {MediaType} from "@/utils/jellyseerr/server/constants/media"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; const exampleSearches = [ "Lord of the rings", "Avengers", "Game of Thrones", "Breaking Bad", "Stranger Things", "The Mandalorian", ]; export default function search() { const params = useLocalSearchParams(); const insets = useSafeAreaInsets(); const { q, prev } = params as { q: string; prev: Href }; const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 500); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [settings] = useSettings(); const { jellyseerrApi } = useJellyseerr(); const searchEngine = useMemo(() => { return settings?.searchEngine || "Jellyfin"; }, [settings]); useEffect(() => { if (q && q.length > 0) setSearch(q); }, [q]); const searchFn = useCallback( async ({ types, query, }: { types: BaseItemKind[]; query: string; }): Promise => { if (!api || !query) return []; try { if (searchEngine === "Jellyfin") { const searchApi = await getSearchApi(api).getSearchHints({ searchTerm: query, limit: 10, includeItemTypes: types, }); return (searchApi.data.SearchHints as BaseItemDto[]) || []; } else { if (!settings?.marlinServerUrl) return []; const url = `${ settings.marlinServerUrl }/search?q=${encodeURIComponent(query)}&includeItemTypes=${types .map((type) => encodeURIComponent(type)) .join("&includeItemTypes=")}`; const response1 = await axios.get(url); const ids = response1.data.ids; if (!ids || !ids.length) return []; const response2 = await getItemsApi(api).getItems({ ids, enableImageTypes: ["Primary", "Backdrop", "Thumb"], }); return (response2.data.Items as BaseItemDto[]) || []; } } catch (error) { console.error("Error during search:", error); return []; // Ensure an empty array is returned in case of an error } }, [api, searchEngine, settings] ); const navigation = useNavigation(); useLayoutEffect(() => { if (Platform.OS === "ios") navigation.setOptions({ headerSearchBarOptions: { placeholder: "Search...", onChangeText: (e: any) => { router.setParams({ q: "" }); setSearch(e.nativeEvent.text); }, hideWhenScrolling: false, autoFocus: true, }, }); }, [navigation]); const { data: movies, isFetching: l1 } = useQuery({ queryKey: ["search", "movies", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["Movie"], }), enabled: debouncedSearch.length > 0, }); const { data: jellyseerrResults, isFetching: r1 } = useQuery({ queryKey: ["search", "jellyseerrResults", debouncedSearch], queryFn: async () => { const response = await jellyseerrApi?.search({ query: new URLSearchParams(debouncedSearch).toString(), page: 1, // todo: maybe rework page & page-size if first results are not enough... language: 'en' }) return response?.results; }, enabled: !!jellyseerrApi && debouncedSearch.length > 0, }); const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(() => jellyseerrResults?.filter(r => r.mediaType === MediaType.MOVIE) as MovieResult[], [jellyseerrResults] ) const jellyseerrTvResults: TvResult[] | undefined = useMemo(() => jellyseerrResults?.filter(r => r.mediaType === MediaType.TV) as TvResult[], [jellyseerrResults] ) const { data: series, isFetching: l2 } = useQuery({ queryKey: ["search", "series", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["Series"], }), enabled: debouncedSearch.length > 0, }); const { data: episodes, isFetching: l3 } = useQuery({ queryKey: ["search", "episodes", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["Episode"], }), enabled: debouncedSearch.length > 0, }); const { data: collections, isFetching: l7 } = useQuery({ queryKey: ["search", "collections", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["BoxSet"], }), enabled: debouncedSearch.length > 0, }); const { data: actors, isFetching: l8 } = useQuery({ queryKey: ["search", "actors", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["Person"], }), enabled: debouncedSearch.length > 0, }); const { data: artists, isFetching: l4 } = useQuery({ queryKey: ["search", "artists", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["MusicArtist"], }), enabled: debouncedSearch.length > 0, }); const { data: albums, isFetching: l5 } = useQuery({ queryKey: ["search", "albums", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["MusicAlbum"], }), enabled: debouncedSearch.length > 0, }); const { data: songs, isFetching: l6 } = useQuery({ queryKey: ["search", "songs", debouncedSearch], queryFn: () => searchFn({ query: debouncedSearch, types: ["Audio"], }), enabled: debouncedSearch.length > 0, }); const noResults = useMemo(() => { return !( artists?.length || albums?.length || songs?.length || movies?.length || episodes?.length || series?.length || collections?.length || actors?.length || jellyseerrMovieResults?.length || jellyseerrTvResults?.length ); }, [artists, episodes, albums, songs, movies, series, collections, actors, jellyseerrResults]); const loading = useMemo(() => { return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8; }, [l1, l2, l3, l4, l5, l6, l7, l8]); return ( <> {Platform.OS === "android" && ( setSearch(text)} /> )} {!!q && ( Results for {q} )} m.Id!)} renderItem={(item: BaseItemDto) => ( {item.Name} {item.ProductionYear} )} /> ( )} /> m.Id!)} header="Series" renderItem={(item: BaseItemDto) => ( {item.Name} {item.ProductionYear} )} /> ( )} /> m.Id!)} header="Episodes" renderItem={(item: BaseItemDto) => ( )} /> m.Id!)} header="Collections" renderItem={(item: BaseItemDto) => ( {item.Name} )} /> m.Id!)} header="Actors" renderItem={(item: BaseItemDto) => ( )} /> m.Id!)} header="Artists" renderItem={(item: BaseItemDto) => ( )} /> m.Id!)} header="Albums" renderItem={(item: BaseItemDto) => ( )} /> m.Id!)} header="Songs" renderItem={(item: BaseItemDto) => ( )} /> {loading ? ( ) : noResults && debouncedSearch.length > 0 ? ( No results found for "{debouncedSearch}" ) : debouncedSearch.length === 0 ? ( {exampleSearches.map((e) => ( setSearch(e)} key={e} className="mb-2" > {e} ))} ) : null} ); } type Props = { ids?: string[] | null; items?: T[]; renderItem: (item: any) => React.ReactNode; header?: string; }; const SearchItemWrapper = ({ ids, items, renderItem, header }: PropsWithChildren>) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { data, isLoading: l1 } = useQuery({ queryKey: ["items", ids], queryFn: async () => { if (!user?.Id || !api || !ids || ids.length === 0) { return []; } const itemPromises = ids.map((id) => getUserItemData({ api, userId: user.Id, itemId: id, }) ); const results = await Promise.all(itemPromises); // Filter out null items return results.filter( (item) => item !== null ) as unknown as BaseItemDto[]; }, enabled: !!ids && ids.length > 0 && !!api && !!user?.Id, staleTime: Infinity, }); if (!data && (!items || items.length === 0)) return null; return ( <> {header} { data && data?.length > 0 ? data.map((item) => renderItem(item)) : items && items?.length > 0 ? items.map(i => renderItem(i)) : undefined } ); };