From 9f12ee027f089f04326a70ee5d2c3cc0256688f5 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Sat, 21 Dec 2024 20:26:25 -0500 Subject: [PATCH] Jellyseerr Integration ## Note this is early stages of said integration. Things will change! series and season working - added jellyseerr git submodule - augmentations - working jellyseerr search integration - working jellyseerr requests & updated interceptors to persist cookies from every response --- .gitmodules | 4 + app/(auth)/(tabs)/(home)/settings.tsx | 8 +- .../jellyseerr/page.tsx | 251 ++++++++++++++++ app/(auth)/(tabs)/(search)/_layout.tsx | 6 +- app/(auth)/(tabs)/(search)/index.tsx | 84 +++++- app/_layout.tsx | 1 + augmentations/index.ts | 2 + augmentations/mmkv.ts | 17 ++ augmentations/number.ts | 22 ++ components/GenreTags.tsx | 27 +- components/Ratings.tsx | 81 +++++ components/RoundButton.tsx | 4 +- components/common/JellyseerrItemRouter.tsx | 103 +++++++ components/downloads/DownloadSize.tsx | 4 +- components/icons/JellyseerrIconStatus.tsx | 72 +++++ components/posters/JellyseerrPoster.tsx | 92 ++++++ components/series/JellyseerrSeasons.tsx | 215 ++++++++++++++ components/settings/SettingToggles.tsx | 119 +++++++- components/stacks/NestedTabPageStack.tsx | 2 +- hooks/useJellyseerr.ts | 281 ++++++++++++++++++ package.json | 9 +- providers/DownloadProvider.tsx | 13 - utils/atoms/settings.ts | 2 + utils/jellyseerr | 1 + 24 files changed, 1368 insertions(+), 52 deletions(-) create mode 100644 .gitmodules create mode 100644 app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx create mode 100644 augmentations/index.ts create mode 100644 augmentations/mmkv.ts create mode 100644 augmentations/number.ts create mode 100644 components/common/JellyseerrItemRouter.tsx create mode 100644 components/icons/JellyseerrIconStatus.tsx create mode 100644 components/posters/JellyseerrPoster.tsx create mode 100644 components/series/JellyseerrSeasons.tsx create mode 100644 hooks/useJellyseerr.ts create mode 160000 utils/jellyseerr diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..32e4ce9f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "utils/jellyseerr"] + path = utils/jellyseerr + url = https://github.com/herrrta/jellyseerr + branch = models diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 05a78ead..46aecbae 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles"; -import { bytesToReadable, useDownload } from "@/providers/DownloadProvider"; +import {useDownload} from "@/providers/DownloadProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { clearLogs, useLog } from "@/utils/log"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; @@ -122,7 +122,7 @@ export default function settings() { Storage - {size && App usage: {bytesToReadable(size.app)}} + {size && App usage: {size.app.bytesToReadable()}} {size && ( - Available: {bytesToReadable(size.remaining)}, Total:{" "} - {bytesToReadable(size.total)} + Available: {size.remaining?.bytesToReadable()}, Total:{" "} + {size.total?.bytesToReadable()} )} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx new file mode 100644 index 00000000..bd778042 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx @@ -0,0 +1,251 @@ +import React, {useCallback, useRef, useState} from "react"; +import {useLocalSearchParams} from "expo-router"; +import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import {Text} from "@/components/common/Text"; +import {ParallaxScrollView} from "@/components/ParallaxPage"; +import {Image} from "expo-image"; +import {TouchableOpacity, View} from "react-native"; +import {Ionicons} from "@expo/vector-icons"; +import {useSafeAreaInsets} from "react-native-safe-area-context"; +import {OverviewText} from "@/components/OverviewText"; +import {GenreTags} from "@/components/GenreTags"; +import {MediaType} from "@/utils/jellyseerr/server/constants/media"; +import {useQuery} from "@tanstack/react-query"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {Button} from "@/components/Button"; +import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; +import {IssueType, IssueTypeName} from "@/utils/jellyseerr/server/constants/issue"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import {Input} from "@/components/common/Input"; +import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; +import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; +import {JellyserrRatings} from "@/components/Ratings"; + +const Page: React.FC = () => { + const insets = useSafeAreaInsets(); + const params = useLocalSearchParams(); + const {mediaTitle, releaseYear, canRequest: canRequestString, posterSrc, ...result} = + params as unknown as {mediaTitle: string, releaseYear: number, canRequest: string, posterSrc: string} & Partial; + + const canRequest = canRequestString === "true"; + const {jellyseerrApi, requestMedia} = useJellyseerr(); + + const [issueType, setIssueType] = useState(); + const [issueMessage, setIssueMessage] = useState(); + const bottomSheetModalRef = useRef(null); + + const {data: details, isLoading} = useQuery({ + enabled: !!jellyseerrApi && !!result && !!result.id, + queryKey: ["jellyseerr", "detail", result.mediaType, result.id], + staleTime: 0, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + retryOnMount: true, + queryFn: async () => { + return result.mediaType === MediaType.MOVIE + ? jellyseerrApi?.movieDetails(result.id!!) + : jellyseerrApi?.tvDetails(result.id!!) + } + }); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + + const submitIssue = useCallback(() => { + if (result.id && issueType && issueMessage && details) { + jellyseerrApi?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage) + .then(() => { + setIssueType(undefined) + setIssueMessage(undefined) + bottomSheetModalRef?.current?.close() + }) + } + }, [jellyseerrApi, details, result, issueType, issueMessage]) + + const request = useCallback(() => requestMedia(mediaTitle, { + mediaId: Number(result.id!!), + mediaType: result.mediaType!!, + tvdbId: details?.externalIds?.tvdbId, + seasons: (details as TvDetails)?.seasons.filter(s => s.seasonNumber !== 0).map(s => s.seasonNumber) + }), [details, result, requestMedia]); + + return ( + + + {result.backdropPath ? ( + + ) : ( + + + + )} + + } + > + + + <> + + + + {mediaTitle} + {releaseYear} + + + + + g.name) || []} /> + {canRequest ? + + : + + } + + + {result.mediaType === MediaType.TV && + + } + + + + + + + + Whats wrong? + + + + + + + Issue Type + + + {issueType ? IssueTypeName[issueType] : 'Select an issue' } + + + + + + Types + {Object.entries(IssueTypeName).reverse().map(([key, value], idx) => ( + setIssueType(key as unknown as IssueType)} + > + {value} + + ))} + + + + + + + + + + + + ); +} + +export default Page; \ No newline at end of file diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index 097cc1cc..2917f1da 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -1,4 +1,4 @@ -import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack"; import { Stack } from "expo-router"; import { Platform } from "react-native"; @@ -29,6 +29,10 @@ export default function SearchLayout() { headerShadowVisible: false, }} /> + ); } diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 93691a3b..4e6e072e 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -20,6 +20,7 @@ import axios from "axios"; import { Href, router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { + PropsWithChildren, useCallback, useEffect, useLayoutEffect, @@ -29,6 +30,10 @@ import 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", @@ -53,6 +58,7 @@ export default function search() { const [user] = useAtom(userAtom); const [settings] = useSettings(); + const { jellyseerrApi } = useJellyseerr(); const searchEngine = useMemo(() => { return settings?.searchEngine || "Jellyfin"; @@ -135,6 +141,30 @@ export default function search() { 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: () => @@ -214,9 +244,11 @@ export default function search() { episodes?.length || series?.length || collections?.length || - actors?.length + actors?.length || + jellyseerrMovieResults?.length || + jellyseerrTvResults?.length ); - }, [artists, episodes, albums, songs, movies, series, collections, actors]); + }, [artists, episodes, albums, songs, movies, series, collections, actors, jellyseerrResults]); const loading = useMemo(() => { return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8; @@ -255,7 +287,7 @@ export default function search() { m.Id!)} - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( )} /> + ( + + )} + /> m.Id!)} header="Series" - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( )} /> + ( + + )} + /> m.Id!)} header="Episodes" - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( m.Id!)} header="Collections" - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( m.Id!)} header="Actors" - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( m.Id!)} header="Artists" - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( m.Id!)} header="Albums" - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( m.Id!)} header="Songs" - renderItem={(item) => ( + renderItem={(item: BaseItemDto) => ( = { ids?: string[] | null; - renderItem: (item: BaseItemDto) => React.ReactNode; + items?: T[]; + renderItem: (item: any) => React.ReactNode; header?: string; }; -const SearchItemWrapper: React.FC = ({ ids, renderItem, header }) => { +const SearchItemWrapper = ({ ids, items, renderItem, header }: PropsWithChildren>) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -444,7 +491,7 @@ const SearchItemWrapper: React.FC = ({ ids, renderItem, header }) => { staleTime: Infinity, }); - if (!data) return null; + if (!data && (!items || items.length === 0)) return null; return ( <> @@ -454,7 +501,14 @@ const SearchItemWrapper: React.FC = ({ ids, renderItem, header }) => { className="px-4 mb-2" showsHorizontalScrollIndicator={false} > - {data.map((item) => renderItem(item))} + { + data && data?.length > 0 + ? data.map((item) => renderItem(item)) + : + items && items?.length > 0 + ? items.map(i => renderItem(i)) + : undefined + } ); diff --git a/app/_layout.tsx b/app/_layout.tsx index c4d9debb..fc8eb4cc 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,3 +1,4 @@ +import "@/augmentations"; import { DownloadProvider } from "@/providers/DownloadProvider"; import { getOrSetDeviceId, diff --git a/augmentations/index.ts b/augmentations/index.ts new file mode 100644 index 00000000..799d5c9d --- /dev/null +++ b/augmentations/index.ts @@ -0,0 +1,2 @@ +export * from "./number"; +export * from "./mmkv"; \ No newline at end of file diff --git a/augmentations/mmkv.ts b/augmentations/mmkv.ts new file mode 100644 index 00000000..80fbeede --- /dev/null +++ b/augmentations/mmkv.ts @@ -0,0 +1,17 @@ +import {MMKV} from "react-native-mmkv"; + +declare module "react-native-mmkv" { + interface MMKV { + get(key: string): T | undefined + setAny(key: string, value: any | undefined): void + } +} + +MMKV.prototype.get = function (key: string): T | undefined { + const serializedItem = this.getString(key); + return serializedItem ? JSON.parse(serializedItem) : undefined; +} + +MMKV.prototype.setAny = function (key: string, value: any | undefined): void { + this.set(key, JSON.stringify(value)); +} \ No newline at end of file diff --git a/augmentations/number.ts b/augmentations/number.ts new file mode 100644 index 00000000..fb5ed2d6 --- /dev/null +++ b/augmentations/number.ts @@ -0,0 +1,22 @@ +declare global { + interface Number { + bytesToReadable(): string; + } +} + +Number.prototype.bytesToReadable = function () { + const bytes = this.valueOf(); + const gb = bytes / 1e9; + + if (gb >= 1) return `${gb.toFixed(2)} GB`; + + const mb = bytes / 1024.0 / 1024.0; + if (mb >= 1) return `${mb.toFixed(2)} MB`; + + const kb = bytes / 1024.0; + if (kb >= 1) return `${kb.toFixed(2)} KB`; + + return `${bytes.toFixed(2)} B`; +} + +export {}; \ No newline at end of file diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index c555fba4..fe834f79 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -1,22 +1,31 @@ // GenreTags.tsx import React from "react"; -import { View } from "react-native"; +import {View, ViewProps} from "react-native"; import { Text } from "./common/Text"; -interface GenreTagsProps { - genres?: string[]; +interface TagProps { + tags?: string[]; + textClass?: ViewProps["className"] } -export const GenreTags: React.FC = ({ genres }) => { - if (!genres || genres.length === 0) return null; +export const Tags: React.FC = ({ tags, textClass = "text-xs", ...props }) => { + if (!tags || tags.length === 0) return null; return ( - - {genres.map((genre, idx) => ( - - {genre} + + {tags.map((genre, idx) => ( + + {genre} ))} ); }; + +export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => { + return ( + + + + ); +}; diff --git a/components/Ratings.tsx b/components/Ratings.tsx index 51b6be3b..790eef34 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native"; import { Badge } from "./Badge"; import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; +import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {useQuery} from "@tanstack/react-query"; +import {MediaType} from "@/utils/jellyseerr/server/constants/media"; interface Props extends ViewProps { item?: BaseItemDto | null; @@ -40,3 +44,80 @@ export const Ratings: React.FC = ({ item, ...props }) => { ); }; + + +export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ result }) => { + const {jellyseerrApi} = useJellyseerr(); + const { data, isLoading } = useQuery({ + queryKey: ['jellyseerr', result.id, result.mediaType, 'ratings'], + queryFn: async () => { + return result.mediaType === MediaType.MOVIE + ? jellyseerrApi?.movieRatings(result.id) + : jellyseerrApi?.tvRatings(result.id) + }, + enabled: !!jellyseerrApi + }); + + return (isLoading || !!result.voteCount || + (data?.criticsRating && !!data?.criticsScore) || + (data?.audienceRating && !!data?.audienceScore)) && ( + + {data?.criticsRating && !!data?.criticsScore && ( + + } + /> + )} + {data?.audienceRating && !!data?.audienceScore && ( + + } + /> + )} + {!!result.voteCount && ( + + } + /> + )} + + ) +} \ No newline at end of file diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx index 49d45cba..6feafae5 100644 --- a/components/RoundButton.tsx +++ b/components/RoundButton.tsx @@ -9,7 +9,7 @@ import { import * as Haptics from "expo-haptics"; interface Props extends TouchableOpacityProps { - onPress: () => void; + onPress?: () => void, icon?: keyof typeof Ionicons.glyphMap; background?: boolean; size?: "default" | "large"; @@ -34,7 +34,7 @@ export const RoundButton: React.FC> = ({ if (hapticFeedback) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } - onPress(); + onPress?.(); }; if (fillColor) diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/JellyseerrItemRouter.tsx new file mode 100644 index 00000000..90f9c336 --- /dev/null +++ b/components/common/JellyseerrItemRouter.tsx @@ -0,0 +1,103 @@ +import {useRouter, useSegments} from "expo-router"; +import React, {PropsWithChildren, useCallback, useMemo} from "react"; +import {TouchableOpacity, TouchableOpacityProps} from "react-native"; +import * as ContextMenu from "zeego/context-menu"; +import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions"; +import {MediaType} from "@/utils/jellyseerr/server/constants/media"; + +interface Props extends TouchableOpacityProps { + result: MovieResult | TvResult; + mediaTitle: string; + releaseYear: number; + canRequest: boolean; + posterSrc: string; +} + +export const TouchableJellyseerrRouter: React.FC> = ({ + result, + mediaTitle, + releaseYear, + canRequest, + posterSrc, + children, + ...props +}) => { + const router = useRouter(); + const segments = useSegments(); + const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr() + + const from = segments[2]; + + const autoApprove = useMemo(() => { + return jellyseerrUser && hasPermission( + Permission.AUTO_APPROVE, + jellyseerrUser.permissions, + {type: 'or'} + ) + }, [jellyseerrApi, jellyseerrUser]) + + const request = useCallback(() => + requestMedia(mediaTitle, { + mediaId: result.id, + mediaType: result.mediaType + } + ), + [jellyseerrApi, result] + ) + + if (from === "(home)" || from === "(search)" || from === "(libraries)") + return ( + <> + + + { + // @ts-ignore + router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}}); + }} + {...props} + > + {children} + + + + Actions + {canRequest && result.mediaType === MediaType.MOVIE && ( + { + if (autoApprove) { + request() + } + }} + shouldDismissMenuOnSelect + > + Request + + + )} + + + + ); +}; diff --git a/components/downloads/DownloadSize.tsx b/components/downloads/DownloadSize.tsx index 7e9f2929..48a52a29 100644 --- a/components/downloads/DownloadSize.tsx +++ b/components/downloads/DownloadSize.tsx @@ -1,5 +1,5 @@ import { Text } from "@/components/common/Text"; -import { bytesToReadable, useDownload } from "@/providers/DownloadProvider"; +import { useDownload } from "@/providers/DownloadProvider"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import React, { useEffect, useMemo, useState } from "react"; import { TextProps } from "react-native"; @@ -29,7 +29,7 @@ export const DownloadSize: React.FC = ({ s += size; } } - setSize(bytesToReadable(s)); + setSize(s.bytesToReadable()); }, [itemIds]); const sizeText = useMemo(() => { diff --git a/components/icons/JellyseerrIconStatus.tsx b/components/icons/JellyseerrIconStatus.tsx new file mode 100644 index 00000000..4c1bda37 --- /dev/null +++ b/components/icons/JellyseerrIconStatus.tsx @@ -0,0 +1,72 @@ +import {useEffect, useState} from "react"; +import {MediaStatus} from "@/utils/jellyseerr/server/constants/media"; +import {MaterialCommunityIcons} from "@expo/vector-icons"; +import {TouchableOpacity, View, ViewProps} from "react-native"; +import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; + +interface Props { + mediaStatus?: MediaStatus; + showRequestIcon: boolean; + onPress?: () => void; +} + +const JellyseerrIconStatus: React.FC = ({ + mediaStatus, + showRequestIcon, + onPress, + ...props +}) => { + const [badgeIcon, setBadgeIcon] = useState(); + const [badgeStyle, setBadgeStyle] = useState(); + + // Match similar to what Jellyseerr is currently using + // https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4 + useEffect(() => { + switch (mediaStatus) { + case MediaStatus.PROCESSING: + setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'); + setBadgeIcon('clock'); + break; + case MediaStatus.AVAILABLE: + setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100'); + setBadgeIcon('check') + break; + case MediaStatus.PENDING: + setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'); + setBadgeIcon('bell') + break; + case MediaStatus.BLACKLISTED: + setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white'); + setBadgeIcon('eye-off') + break; + case MediaStatus.PARTIALLY_AVAILABLE: + setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100'); + setBadgeIcon("minus"); + break; + default: + if (showRequestIcon) { + setBadgeStyle('bg-green-600'); + setBadgeIcon("plus") + } + break; + } + }, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon]) + + return ( + badgeIcon && + + + + + + ) +} + +export default JellyseerrIconStatus; \ No newline at end of file diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx new file mode 100644 index 00000000..5a9647ae --- /dev/null +++ b/components/posters/JellyseerrPoster.tsx @@ -0,0 +1,92 @@ +import {View, ViewProps} from "react-native"; +import {Image} from "expo-image"; +import {MaterialCommunityIcons} from "@expo/vector-icons"; +import {Text} from "@/components/common/Text"; +import {useEffect, useMemo, useState} from "react"; +import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions"; +import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter"; +import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus"; +interface Props extends ViewProps { + item: MovieResult | TvResult; +} + +const JellyseerrPoster: React.FC = ({ + item, + ...props +}) => { + const {jellyseerrUser, jellyseerrApi} = useJellyseerr(); + // const imageSource = + + const imageSrc = useMemo(() => + item.posterPath ? + `https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}` + : jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`, + [item, jellyseerrApi] + ) + const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item]) + const releaseYear = useMemo(() => + new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(), + [item] + ) + + const showRequestButton = useMemo(() => + jellyseerrUser && hasPermission( + [ + Permission.REQUEST, + item.mediaType === 'movie' + ? Permission.REQUEST_MOVIE + : Permission.REQUEST_TV, + ], + jellyseerrUser.permissions, + {type: 'or'} + ), + [item, jellyseerrUser] + ) + + const canRequest = useMemo(() => { + const status = item?.mediaInfo?.status + return showRequestButton && !status || status === MediaStatus.UNKNOWN + }, [item]) + + return ( + + + + + + + + + {title} + {releaseYear} + + + + ) +} + + +export default JellyseerrPoster; \ No newline at end of file diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx new file mode 100644 index 00000000..a62689b9 --- /dev/null +++ b/components/series/JellyseerrSeasons.tsx @@ -0,0 +1,215 @@ +import {Text} from "@/components/common/Text"; +import React, {useCallback, useMemo, useState} from "react"; +import {Alert, TouchableOpacity, View} from "react-native"; +import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; +import {FlashList} from "@shopify/flash-list"; +import {orderBy} from "lodash"; +import {Tags} from "@/components/GenreTags"; +import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus"; +import Season from "@/utils/jellyseerr/server/entity/Season"; +import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; +import {Ionicons} from "@expo/vector-icons"; +import {RoundButton} from "@/components/RoundButton"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {TvResult} from "@/utils/jellyseerr/server/models/Search"; +import {useQuery} from "@tanstack/react-query"; +import {HorizontalScroll} from "@/components/common/HorrizontalScroll"; +import {Image} from "expo-image"; +import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; + +const JellyseerrSeasonEpisodes: React.FC<{details: TvDetails, seasonNumber: number}> = ({ + details, + seasonNumber +}) => { + const {jellyseerrApi} = useJellyseerr(); + + const {data: seasonWithEpisodes, isLoading} = useQuery({ + queryKey: ["jellyseerr", details.id, "season", seasonNumber], + queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber), + enabled: details.seasons.filter(s => s.seasonNumber !== 0).length > 0 + }) + + return ( + item.id} + ItemSeparatorComponent={() => } + renderItem={(item, index) => ( + + {item.stillPath && ( + + + + )} + + + {item?.name} + + + {`S${item?.seasonNumber}:E${item?.episodeNumber}`} + + + + + {item?.overview} + + + )} + /> + ) +} + +const JellyseerrSeasons: React.FC<{ + isLoading: boolean, + result?: TvResult, + details?: TvDetails +}> = ({ + isLoading, + result, + details, +}) => { + if (!details) + return null; + + const {jellyseerrApi, requestMedia} = useJellyseerr(); + const [seasonStates, setSeasonStates] = useState<{[key: number]: boolean}>(); + const seasons = useMemo(() => { + const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter((s: Season) => s.seasonNumber !== 0) + const requestedSeasons = details?.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) + return details.seasons?.map((season) => { + return { + ...season, + status: + // What our library status is + mediaInfoSeasons + ?.find((mediaSeason: Season) => mediaSeason.seasonNumber === season.seasonNumber) + ?.status + ?? + // What our request status is + requestedSeasons + ?.find((s: Season) => s.seasonNumber === season.seasonNumber) + ?.status + ?? + // Otherwise set it as unknown + MediaStatus.UNKNOWN + } + }) + }, + [details] + ); + + const allSeasonsAvailable = useMemo(() => + seasons?.every(season => season.status === MediaStatus.AVAILABLE), + [seasons] + ) + + const requestAll = useCallback(() => { + if (details && jellyseerrApi) { + requestMedia(result?.name!!, { + mediaId: details.id, + mediaType: MediaType.TV, + tvdbId: details.externalIds?.tvdbId, + seasons: seasons + .filter(s => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0) + .map(s => s.seasonNumber) + }) + } + }, [jellyseerrApi, seasons, details]) + + const promptRequestAll = useCallback(() => ( + Alert.alert('Request all?', 'Are you sure you want to request all seasons?', [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'YES', + onPress: requestAll + }, + ])), [requestAll]); + + return ( + s.seasonNumber !== 0), 'seasonNumber', 'desc')} + ListHeaderComponent={() => ( + + Seasons + {!allSeasonsAvailable && ( + + + + )} + + )} + ItemSeparatorComponent={() => } + estimatedItemSize={250} + renderItem={({item: season}) => ( + <> + setSeasonStates((prevState) => ( + {...prevState, [season.seasonNumber]: !prevState?.[season.seasonNumber]} + ))} + > + + + {[0].map(() => { + const canRequest = seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status === MediaStatus.UNKNOWN + return + requestMedia( + `${result?.name!!}, Season ${season.seasonNumber}`, + { + mediaId: details.id, + mediaType: MediaType.TV, + tvdbId: details.externalIds?.tvdbId, + seasons: [season.seasonNumber] + } + ) : undefined + } + className={canRequest ? 'bg-gray-700/40' : undefined} + mediaStatus={seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status} + showRequestIcon={canRequest} + /> + })} + + + {seasonStates?.[season.seasonNumber] && ( + + )} + + ) + } + /> + ) +} + +export default JellyseerrSeasons; \ No newline at end of file diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 2ad00ad8..8302c45b 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -21,8 +21,9 @@ import * as BackgroundFetch from "expo-background-fetch"; import * as ScreenOrientation from "expo-screen-orientation"; import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; +import React, {useCallback, useEffect, useState} from "react"; import { + Alert, Linking, Switch, TouchableOpacity, @@ -40,20 +41,32 @@ import { Stepper } from "@/components/inputs/Stepper"; import { MediaProvider } from "./MediaContext"; import { SubtitleToggles } from "./SubtitleToggles"; import { AudioToggles } from "./AudioToggles"; +import {JellyseerrApi, useJellyseerr} from "@/hooks/useJellyseerr"; +import {ListItem} from "@/components/ListItem"; interface Props extends ViewProps {} export const SettingToggles: React.FC = ({ ...props }) => { const [settings, updateSettings] = useSettings(); const { setProcesses } = useDownload(); + const { + jellyseerrApi, + jellyseerrUser, + setJellyseerrUser , + clearAllJellyseerData + } = useJellyseerr(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [marlinUrl, setMarlinUrl] = useState(""); + const [jellyseerrPassword, setJellyseerrPassword] = useState(undefined); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = useState(settings?.optimizedVersionsServerUrl || ""); + const [jellyseerrServerUrl, setjellyseerrServerUrl] = + useState(settings?.jellyseerrServerUrl || undefined); + const queryClient = useQueryClient(); /******************** @@ -108,6 +121,51 @@ export const SettingToggles: React.FC = ({ ...props }) => { staleTime: 0, }); + const promptForJellyseerrLogin = useCallback(() => + Alert.prompt( + 'Enter jellyfin password', + `Enter password for jellyfin user ${user?.Name}`, + (input) => setJellyseerrPassword(input), + 'secure-text' + ), + [user, setJellyseerrPassword] + ); + + const testJellyseerrServerUrl = useCallback(async () => { + if (!jellyseerrServerUrl || jellyseerrApi) + return; + + const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); + + jellyseerrTempApi.test().then(result => { + if (result.isValid) { + + if (result.requiresPass) + promptForJellyseerrLogin() + else + updateSettings({jellyseerrServerUrl}) + } + else { + setjellyseerrServerUrl(undefined); + clearAllJellyseerData(); + } + }) + }, [jellyseerrServerUrl]) + + useEffect(() => { + if (jellyseerrServerUrl && user?.Name && jellyseerrPassword) { + const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); + jellyseerrTempApi.login(user?.Name, jellyseerrPassword) + .then(user => { + setJellyseerrUser(user); + updateSettings({jellyseerrServerUrl}) + }) + .finally(() => { + setJellyseerrPassword(undefined); + }) + } + }, [user, jellyseerrServerUrl, jellyseerrPassword]); + if (!settings) return null; return ( @@ -633,6 +691,65 @@ export const SettingToggles: React.FC = ({ ...props }) => { + + + Jellyseerr + + {jellyseerrUser && <> + + + + + + + + } + + + + + + Server URL + + + + Set the URL for your jellyseerr instance. + + This integration is in its early stages. Expect things to change. + + + + + + + ); }; diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index b74c71af..024b1272 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -9,7 +9,7 @@ type ICommonScreenOptions = navigation: any; }) => NativeStackNavigationOptions); -const commonScreenOptions: ICommonScreenOptions = { +export const commonScreenOptions: ICommonScreenOptions = { title: "", headerShown: true, headerTransparent: true, diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts new file mode 100644 index 00000000..eafe7c57 --- /dev/null +++ b/hooks/useJellyseerr.ts @@ -0,0 +1,281 @@ +import axios, {AxiosError, AxiosInstance} from "axios"; +import {Results} from "@/utils/jellyseerr/server/models/Search"; +import { storage } from "@/utils/mmkv"; +import {inRange} from "lodash"; +import {User as JellyseerrUser} from "@/utils/jellyseerr/server/entity/User"; +import {atom} from "jotai"; +import {useAtom} from "jotai/index"; +import "@/augmentations"; +import {useCallback, useMemo} from "react"; +import {useSettings} from "@/utils/atoms/settings"; +import {toast} from "sonner-native"; +import {MediaRequestStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; +import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; +import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; +import {SeasonWithEpisodes, TvDetails} from "@/utils/jellyseerr/server/models/Tv"; +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"; + +interface SearchParams { + query: string, + page: number, + language: string; +} + +interface SearchResults { + page: number, + total_pages: number, + total_results: number; + results: Results[]; +} + +const JELLYSEERR_USER = "JELLYSEERR_USER" +const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES" + +export const clearJellyseerrStorageData = () => { + storage.delete(JELLYSEERR_USER); + storage.delete(JELLYSEERR_COOKIES); +} + +enum Endpoints { + STATUS = "/status", + API_V1 = "/api/v1", + SEARCH = "/search", + REQUEST = "/request", + MOVIE = "/movie", + RATINGS = "/ratings", + ISSUE = "/issue", + TV = "/tv", + AUTH_JELLYFIN = "/auth/jellyfin", +} + +export type TestResult = { + isValid: true; + requiresPass: boolean; +} | { + isValid: false; +}; + +export class JellyseerrApi { + axios: AxiosInstance; + + constructor (baseUrl: string) { + this.axios = axios.create({ + baseURL: baseUrl, + withCredentials: true, + withXSRFToken: true, + xsrfHeaderName: "XSRF-TOKEN" + }); + + this.setInterceptors(); + } + + async test(): Promise { + const user = storage.get(JELLYSEERR_USER); + const cookies = storage.get(JELLYSEERR_COOKIES); + + if (user && cookies) { + console.log("User & cookies data exist for jellyseerr") + return Promise.resolve({ + isValid: true, + requiresPass: false + }); + } + + console.log("Testing jellyseerr connection") + return await this.axios.get(Endpoints.API_V1 + Endpoints.STATUS) + .then((response) => { + const {status, headers, data} = response; + if (inRange(status, 200, 299)) { + if (data.version < "2.0.0") { + const error = "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0"; + toast.error(error); + throw Error(error); + } + + console.log("Jellyseerr connecting successfully tested!"); + storage.setAny(JELLYSEERR_COOKIES, headers["set-cookie"]?.flatMap(c => c.split("; "))); + return { + isValid: true, + requiresPass: true + }; + } + return { + isValid: false, + requiresPass: false + }; + }) + .catch((e) => { + console.error("Failed to test jellyseerr server url", e) + return { + isValid: false, + requiresPass: false + }; + }) + } + + async login(username: string, password: string): Promise { + return this.axios?.post(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, { + username, + password, + email: username + }).then(response => { + const user = response.data; + storage.setAny(JELLYSEERR_USER, user); + return user + }) + } + + async search(params: SearchParams): Promise { + const response = await this.axios?.get(Endpoints.API_V1 + Endpoints.SEARCH, {params}) + return response?.data + } + + async request(request: MediaRequestBody): Promise { + return this.axios?.post(Endpoints.API_V1 + Endpoints.REQUEST, request) + .then(({data}) => data) + } + + async movieDetails(id: number) { + return this.axios?.get(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`).then(response => { + return response?.data + }) + } + + + async movieRatings(id: number) { + return this.axios?.get(`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`) + .then(({data}) => data) + } + + async tvDetails(id: number) { + return this.axios?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}`).then(response => { + return response?.data + }) + } + + async tvRatings(id: number) { + return this.axios?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`) + .then(({data}) => data) + } + + async tvSeason(id: number, seasonId: number) { + return this.axios?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`).then(response => { + console.log(response.data.episodes) + return response?.data + }) + } + + tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) { + return this.axios.defaults.baseURL + `/_next/image?` + new URLSearchParams(`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`).toString() + } + + async submitIssue(mediaId: number, issueType: IssueType, message: string) { + return this.axios?.post(Endpoints.API_V1 + Endpoints.ISSUE, { + mediaId, issueType, message + }).then((response) => { + const issue = response.data + + if (issue.status === IssueStatus.OPEN) { + toast.success("Issue submitted!") + } + return issue + }) + } + + private setInterceptors() { + this.axios.interceptors.request.use( + async (config) => { + const cookies = storage.get(JELLYSEERR_COOKIES); + if (config.method !== "head" && cookies) { + const headerName = this.axios.defaults.xsrfHeaderName!!; + const xsrfToken = cookies + .find(c => c.includes(headerName)) + ?.split(headerName + "=")?.[1] + if (xsrfToken) { + config.headers[headerName] = xsrfToken; + } + } + return config + }, + (error) => { + console.error("Jellyseerr request error", error) + } + ); + + this.axios.interceptors.response.use( + async (response) => { + const cookies = response.headers["set-cookie"]; + if (cookies) { + storage.setAny(JELLYSEERR_COOKIES, response.headers["set-cookie"]?.flatMap(c => c.split("; "))); + } + return response; + }, + (error: AxiosError) => { + console.error("Jellyseerr response error:", error,error.response?.data) + if (error.status === 403) { + clearJellyseerrStorageData() + } + } + ); + } +} + +const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER)) + +export const useJellyseerr = () => { + const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); + const [settings, updateSettings] = useSettings(); + + const jellyseerrApi = useMemo(() => { + const cookies = storage.get(JELLYSEERR_COOKIES); + if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) { + return new JellyseerrApi(settings?.jellyseerrServerUrl) + } + return undefined + }, [settings?.jellyseerrServerUrl, jellyseerrUser]) + + const clearAllJellyseerData = useCallback(async () => { + clearJellyseerrStorageData() + setJellyseerrUser(undefined); + updateSettings({jellyseerrServerUrl: undefined}) + }, []); + + const requestMedia = useCallback(( + title: string, + request: MediaRequestBody, + ) => { + jellyseerrApi?.request?.(request)?.then((mediaRequest) => { + switch (mediaRequest.status) { + case MediaRequestStatus.PENDING: + case MediaRequestStatus.APPROVED: + toast.success(`Requested ${title}!`) + break; + case MediaRequestStatus.DECLINED: + toast.error(`You don't have permission to request!`) + break; + case MediaRequestStatus.FAILED: + toast.error(`Something went wrong requesting media!`) + break; + } + }) + }, [jellyseerrApi]) + + const isJellyseerrResult = (items: any[] | null | undefined): items is Results[] => { + return ( + !items || + items.length >= 0 && Object.hasOwn(items[0], "mediaType") && Object.values(MediaType).includes(items[0]['mediaType']) + ) + } + + return { + jellyseerrApi, + jellyseerrUser, + setJellyseerrUser, + clearAllJellyseerData, + isJellyseerrResult, + requestMedia + } +}; diff --git a/package.json b/package.json index 748b16fc..a2e3b635 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "main": "./index", "version": "1.0.0", "scripts": { - "start": "expo start", + "submodule-reload": "git submodule update --init --remote --recursive", + "start": "bun run submodule-reload && expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo run:android", - "ios": "expo run:ios", - "web": "expo start --web", + "android": "bun run submodule-reload && expo run:android", + "ios": "bun run submodule-reload && expo run:ios", + "web": "bun run submodule-reload && expo start --web", "test": "jest --watchAll", "lint": "expo lint", "postinstall": "patch-package" diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 478b990f..706f96f5 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -711,16 +711,3 @@ export function useDownload() { } return context; } - -export function bytesToReadable(bytes: number): string { - const gb = bytes / 1e9; - - if (gb >= 1) return `${gb.toFixed(2)} GB`; - - const mb = bytes / 1024.0 / 1024.0; - if (mb >= 1) return `${mb.toFixed(2)} MB`; - - const kb = bytes / 1024.0; - if (kb >= 1) return `${kb.toFixed(2)} KB`; - return `${bytes.toFixed(2)} B`; -} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index f5f22046..c37dd4eb 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -87,6 +87,7 @@ export type Settings = { subtitleSize: number; remuxConcurrentLimit: 1 | 2 | 3 | 4; safeAreaInControlsEnabled: boolean; + jellyseerrServerUrl?: string; }; const loadSettings = (): Settings => { @@ -124,6 +125,7 @@ const loadSettings = (): Settings => { subtitleSize: Platform.OS === "ios" ? 60 : 100, remuxConcurrentLimit: 1, safeAreaInControlsEnabled: true, + jellyseerrServerUrl: undefined, }; try { diff --git a/utils/jellyseerr b/utils/jellyseerr new file mode 160000 index 00000000..e69d160e --- /dev/null +++ b/utils/jellyseerr @@ -0,0 +1 @@ +Subproject commit e69d160e25f0962cd77b01c861ce248050e1ad38