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
This commit is contained in:
herrrta
2024-12-21 20:26:25 -05:00
parent 78b7425c6b
commit 9f12ee027f
24 changed files with 1368 additions and 52 deletions

View File

@@ -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() {
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2">
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
{size && <Text>App usage: {size.app.bytesToReadable()}</Text>}
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
@@ -135,8 +135,8 @@ export default function settings() {
/>
{size && (
<Text>
Available: {bytesToReadable(size.remaining)}, Total:{" "}
{bytesToReadable(size.total)}
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
{size.total?.bytesToReadable()}
</Text>
)}
</View>

View File

@@ -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<MovieResult | TvResult>;
const canRequest = canRequestString === "true";
const {jellyseerrApi, requestMedia} = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetModal>(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) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
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 (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
headerImage={
<View>
{result.backdropPath ? (
<Image
cachePolicy={"memory-disk"}
transition={300}
style={{
width: "100%",
height: "100%",
}}
source={{
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
}}
/>
) : (
<View
style={{
width: "100%",
height: "100%",
}}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
>
<Ionicons
name="image-outline"
size={24}
color="white"
style={{opacity: 0.4}}
/>
</View>
)}
</View>
}
>
<View className="flex flex-col">
<View className="p-4 space-y-4">
<>
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-56">
<JellyserrRatings result={(result as MovieResult | TvResult)}/>
<Text uiTextView selectable className="font-bold text-2xl mb-1">{mediaTitle}</Text>
<Text className="opacity-50">{releaseYear}</Text>
</View>
<Image
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
cachePolicy={"memory-disk"}
transition={300}
source={{
uri: posterSrc,
}}
/>
</View>
</>
<GenreTags genres={details?.genres?.map(g => g.name) || []} />
{canRequest ?
<Button color="purple" onPress={request}>Request</Button>
:
<Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
color="transparent"
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white"/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}>
Report issue
</Button>
}
<OverviewText text={result.overview} className="mb-4" />
{result.mediaType === MediaType.TV &&
<JellyseerrSeasons
isLoading={isLoading}
result={result as TvResult}
details={details as TvDetails}
/>
}
</View>
</View>
</ParallaxScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">Whats wrong?</Text>
</View>
<View className="flex flex-col space-y-2 items-start">
<View className="flex flex-col">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{issueType ? IssueTypeName[issueType] : 'Select an issue' }
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>Types</DropdownMenu.Label>
{Object.entries(IssueTypeName).reverse().map(([key, value], idx) => (
<DropdownMenu.Item
key={value}
onSelect={() => setIssueType(key as unknown as IssueType)}
>
<DropdownMenu.ItemTitle>{value}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<Input
className="w-full"
placeholder="(optional) Describe the issue..."
value={issueMessage}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="none"
maxLength={254}
onChangeText={setIssueMessage}
/>
</View>
<Button
className="mt-auto"
onPress={submitIssue}
color="purple"
>
Submit
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
}
export default Page;

View File

@@ -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,
}}
/>
<Stack.Screen
name="jellyseerr/page"
options={commonScreenOptions}
/>
</Stack>
);
}

View File

@@ -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() {
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
@@ -271,10 +303,17 @@ export default function search() {
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
@@ -290,10 +329,17 @@ export default function search() {
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
@@ -307,7 +353,7 @@ export default function search() {
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
@@ -323,7 +369,7 @@ export default function search() {
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
@@ -337,7 +383,7 @@ export default function search() {
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
@@ -351,7 +397,7 @@ export default function search() {
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
@@ -365,7 +411,7 @@ export default function search() {
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item) => (
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
@@ -408,13 +454,14 @@ export default function search() {
);
}
type Props = {
type Props<T> = {
ids?: string[] | null;
renderItem: (item: BaseItemDto) => React.ReactNode;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
const SearchItemWrapper = <T extends unknown> ({ ids, items, renderItem, header }: PropsWithChildren<Props<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -444,7 +491,7 @@ const SearchItemWrapper: React.FC<Props> = ({ 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<Props> = ({ 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
}
</ScrollView>
</>
);

View File

@@ -1,3 +1,4 @@
import "@/augmentations";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,