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