From cbce83e1096367d95b38cc31b588f7bb26e50396 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Sun, 29 Dec 2024 10:07:46 -0500 Subject: [PATCH] Basic Jellyseerr discover page --- app/(auth)/(tabs)/(search)/index.tsx | 341 +++++++++++++----------- augmentations/index.ts | 3 +- augmentations/number.ts | 15 ++ augmentations/string.ts | 16 ++ components/GenreTags.tsx | 18 +- components/Ratings.tsx | 4 +- components/jellyseerr/DiscoverSlide.tsx | 75 ++++++ hooks/useJellyseerr.ts | 36 ++- 8 files changed, 346 insertions(+), 162 deletions(-) create mode 100644 augmentations/string.ts create mode 100644 components/jellyseerr/DiscoverSlide.tsx diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 4e6e072e..aaa5b1ab 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -34,6 +34,11 @@ 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"; +import {Tag} from "@/components/GenreTags"; +import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide"; +import {sortBy} from "lodash"; + +type SearchType = 'Library' | 'Discover'; const exampleSearches = [ "Lord of the rings", @@ -50,6 +55,7 @@ export default function search() { const { q, prev } = params as { q: string; prev: Href }; + const [searchType, setSearchType] = useState("Library"); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 500); @@ -138,10 +144,10 @@ export default function search() { query: debouncedSearch, types: ["Movie"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); - const { data: jellyseerrResults, isFetching: r1 } = useQuery({ + const { data: jellyseerrResults, isFetching: j1 } = useQuery({ queryKey: ["search", "jellyseerrResults", debouncedSearch], queryFn: async () => { const response = await jellyseerrApi?.search({ @@ -152,7 +158,13 @@ export default function search() { return response?.results; }, - enabled: !!jellyseerrApi && debouncedSearch.length > 0, + enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length > 0, + }); + + const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({ + queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch], + queryFn: async () => jellyseerrApi?.discoverSettings(), + enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length == 0, }); const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(() => @@ -172,7 +184,7 @@ export default function search() { query: debouncedSearch, types: ["Series"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); const { data: episodes, isFetching: l3 } = useQuery({ @@ -182,7 +194,7 @@ export default function search() { query: debouncedSearch, types: ["Episode"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); const { data: collections, isFetching: l7 } = useQuery({ @@ -192,7 +204,7 @@ export default function search() { query: debouncedSearch, types: ["BoxSet"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); const { data: actors, isFetching: l8 } = useQuery({ @@ -202,7 +214,7 @@ export default function search() { query: debouncedSearch, types: ["Person"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); const { data: artists, isFetching: l4 } = useQuery({ @@ -212,7 +224,7 @@ export default function search() { query: debouncedSearch, types: ["MusicArtist"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); const { data: albums, isFetching: l5 } = useQuery({ @@ -222,7 +234,7 @@ export default function search() { query: debouncedSearch, types: ["MusicAlbum"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); const { data: songs, isFetching: l6 } = useQuery({ @@ -232,7 +244,7 @@ export default function search() { query: debouncedSearch, types: ["Audio"], }), - enabled: debouncedSearch.length > 0, + enabled: searchType === "Library" && debouncedSearch.length > 0, }); const noResults = useMemo(() => { @@ -251,8 +263,8 @@ export default function search() { }, [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 l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2; + }, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]); return ( <> @@ -277,6 +289,18 @@ export default function search() { /> )} + {jellyseerrApi && ( + + setSearchType('Library')}> + + + setSearchType('Discover')}> + + + + )} {!!q && ( @@ -284,144 +308,153 @@ export default function search() { )} - 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) => ( - - - - - )} - /> + {searchType === "Library" && ( + <> + 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) => ( + + + + + )} + /> + + )} + {searchType === "Discover" && ( + <> + ( + + )} + /> + ( + + )} + /> + + )} + {loading ? ( @@ -435,7 +468,7 @@ export default function search() { "{debouncedSearch}" - ) : debouncedSearch.length === 0 ? ( + ) : debouncedSearch.length === 0 && searchType === 'Library' ? ( {exampleSearches.map((e) => ( ))} + ) : debouncedSearch.length === 0 && searchType === 'Discover' ? ( + + {sortBy?.(jellyseerrDiscoverSettings?.filter(s => s.enabled), 'order') + .map((slide) => ) + } + ) : null} diff --git a/augmentations/index.ts b/augmentations/index.ts index 799d5c9d..22ca2cb0 100644 --- a/augmentations/index.ts +++ b/augmentations/index.ts @@ -1,2 +1,3 @@ +export * from "./mmkv"; export * from "./number"; -export * from "./mmkv"; \ No newline at end of file +export * from "./string"; diff --git a/augmentations/number.ts b/augmentations/number.ts index fb5ed2d6..2b7e8dac 100644 --- a/augmentations/number.ts +++ b/augmentations/number.ts @@ -1,6 +1,9 @@ declare global { interface Number { bytesToReadable(): string; + secondsToMilliseconds(): number + minutesToMilliseconds(): number + hoursToMilliseconds(): number } } @@ -19,4 +22,16 @@ Number.prototype.bytesToReadable = function () { return `${bytes.toFixed(2)} B`; } +Number.prototype.secondsToMilliseconds = function () { + return this.valueOf() * 1000 +} + +Number.prototype.minutesToMilliseconds = function () { + return this.valueOf() * (60).secondsToMilliseconds() +} + +Number.prototype.hoursToMilliseconds = function () { + return this.valueOf() * (60).minutesToMilliseconds() +} + export {}; \ No newline at end of file diff --git a/augmentations/string.ts b/augmentations/string.ts new file mode 100644 index 00000000..75a97f05 --- /dev/null +++ b/augmentations/string.ts @@ -0,0 +1,16 @@ +declare global { + interface String { + toTitle(): string; + } +} + +String.prototype.toTitle = function () { + return this + .replaceAll("_", " ") + .replace( + /\w\S*/g, + text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() + ); +} + +export {}; \ No newline at end of file diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index fe834f79..c90ede32 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -8,14 +8,26 @@ interface TagProps { textClass?: ViewProps["className"] } +export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({ + text, + textClass, + ...props +}) => { + return ( + + {text} + + ); +}; + export const Tags: React.FC = ({ tags, textClass = "text-xs", ...props }) => { if (!tags || tags.length === 0) return null; return ( - {tags.map((genre, idx) => ( - - {genre} + {tags.map((tag, idx) => ( + + ))} diff --git a/components/Ratings.tsx b/components/Ratings.tsx index 790eef34..e5eb8fc3 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -55,7 +55,9 @@ export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ r ? jellyseerrApi?.movieRatings(result.id) : jellyseerrApi?.tvRatings(result.id) }, - enabled: !!jellyseerrApi + staleTime: (5).minutesToMilliseconds(), + retry: false, + enabled: !!jellyseerrApi, }); return (isLoading || !!result.voteCount || diff --git a/components/jellyseerr/DiscoverSlide.tsx b/components/jellyseerr/DiscoverSlide.tsx new file mode 100644 index 00000000..94a2d4dd --- /dev/null +++ b/components/jellyseerr/DiscoverSlide.tsx @@ -0,0 +1,75 @@ +import React, {useMemo} from "react"; +import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover"; +import {DiscoverEndpoint, Endpoints, useJellyseerr} from "@/hooks/useJellyseerr"; +import {useInfiniteQuery} from "@tanstack/react-query"; +import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; +import {Text} from "@/components/common/Text"; +import {FlashList} from "@shopify/flash-list"; + +interface Props { + slide: DiscoverSlider +} +const DiscoverSlide: React.FC = ({slide}) => { + const {jellyseerrApi} = useJellyseerr(); + + const {data, isFetching, fetchNextPage, hasNextPage} = useInfiniteQuery({ + queryKey: ["jellyseerr", "discover", slide.id], + queryFn: async ({ pageParam }) => { + let endpoint: DiscoverEndpoint | undefined = undefined; + let params: any = { + page: Number(pageParam) + } + + switch (slide.type) { + case DiscoverSliderType.TRENDING: + endpoint = Endpoints.DISCOVER_TRENDING; + break; + case DiscoverSliderType.POPULAR_MOVIES: + case DiscoverSliderType.UPCOMING_MOVIES: + endpoint = Endpoints.DISCOVER_MOVIES + if (slide.type === DiscoverSliderType.UPCOMING_MOVIES) + params = { ...params, primaryReleaseDateGte: new Date().toISOString().split('T')[0]} + break; + case DiscoverSliderType.POPULAR_TV: + case DiscoverSliderType.UPCOMING_TV: + endpoint = Endpoints.DISCOVER_TV + if (slide.type === DiscoverSliderType.UPCOMING_TV) + params = {...params, firstAirDateGte: new Date().toISOString().split('T')[0]} + break; + } + + return endpoint ? jellyseerrApi?.discover(endpoint, params) : null; + }, + initialPageParam: 1, + getNextPageParam: (lastPage, pages) => ((lastPage?.page || pages?.findLast(p => p?.results.length)?.page) || 1) + 1, + enabled: !!jellyseerrApi, + staleTime: 0 + }); + + const flatData = useMemo(() => data?.pages?.filter(p => p?.results.length).flatMap(p => p?.results), [data]) + + return ( + (flatData && flatData?.length > 0) && <> + {DiscoverSliderType[slide.type].toString().toTitle()} + item!!.id.toString()} + estimatedItemSize={250} + data={flatData} + onEndReachedThreshold={1} + onEndReached={() => { + if (hasNextPage) + fetchNextPage() + }} + renderItem={({item}) => + (item ? : <>) + } + /> + + ) +} + +export default DiscoverSlide; \ No newline at end of file diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index acb69e8f..fefe0ce6 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -18,6 +18,7 @@ import {IssueStatus, IssueType} from "@/utils/jellyseerr/server/constants/issue" import Issue from "@/utils/jellyseerr/server/entity/Issue"; import {RTRating} from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import {writeErrorLog} from "@/utils/log"; +import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; interface SearchParams { query: string, @@ -27,8 +28,8 @@ interface SearchParams { interface SearchResults { page: number, - total_pages: number, - total_results: number; + totalPages: number, + totalResults: number; results: Results[]; } @@ -40,7 +41,7 @@ export const clearJellyseerrStorageData = () => { storage.delete(JELLYSEERR_COOKIES); } -enum Endpoints { +export enum Endpoints { STATUS = "/status", API_V1 = "/api/v1", SEARCH = "/search", @@ -49,9 +50,16 @@ enum Endpoints { RATINGS = "/ratings", ISSUE = "/issue", TV = "/tv", + SETTINGS = "/settings", + DISCOVER = "/discover", + DISCOVER_TRENDING = DISCOVER + "/trending", + DISCOVER_MOVIES = DISCOVER + "/movies", + DISCOVER_TV = DISCOVER + TV, AUTH_JELLYFIN = "/auth/jellyfin", } +export type DiscoverEndpoint = Endpoints.DISCOVER_TRENDING | Endpoints.DISCOVER_MOVIES | Endpoints.DISCOVER_TV; + export type TestResult = { isValid: true; requiresPass: boolean; @@ -115,7 +123,9 @@ export class JellyseerrApi { }; }) .catch((e) => { - console.error("Failed to test jellyseerr server url", e) + const msg = "Failed to test jellyseerr server url"; + toast.error(msg) + console.error(msg, e) return { isValid: false, requiresPass: false @@ -137,6 +147,16 @@ export class JellyseerrApi { }) } + async discoverSettings(): Promise { + return this.axios?.get(Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER) + .then(({data}) => data) + } + + async discover(endpoint: DiscoverEndpoint, params: any): Promise { + return this.axios?.get(Endpoints.API_V1 + endpoint, { params }) + .then(({data}) => data) + } + async search(params: SearchParams): Promise { const response = await this.axios?.get(Endpoints.API_V1 + Endpoints.SEARCH, {params}) return response?.data @@ -204,15 +224,19 @@ export class JellyseerrApi { return response; }, (error: AxiosError) => { - const errorMsg = "Jellyseerr response error:"; + const errorMsg = "Jellyseerr response error"; console.error(errorMsg, error, error.response?.data); writeErrorLog( - errorMsg + ` ${error.toString()}\n` + + errorMsg + `\n` + + `error: ${error.toString()}\n` + + `url: ${error?.config?.url}\n` + + `data:\n` + JSON.stringify(error.response?.data) ); if (error.status === 403) { clearJellyseerrStorageData() } + return Promise.reject(error) } );