diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx
index a9a2e2fb..828bbe5f 100644
--- a/app/(auth)/(tabs)/(home)/settings.tv.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx
@@ -1,12 +1,13 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
-import { useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -21,6 +22,7 @@ import {
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
+import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
- const { settings, updateSettings } = useSettings();
+ const { settings, updateSettings, pluginSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -59,6 +61,51 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
+ const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
+ useJellyseerr();
+
+ // Jellyseerr connection state
+ const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
+ settings.jellyseerrServerUrl || "",
+ );
+ const [jellyseerrPassword, setJellyseerrPassword] = useState("");
+
+ const isJellyseerrLocked =
+ pluginSettings?.jellyseerrServerUrl?.locked === true;
+ const isJellyseerrConnected = !!jellyseerrApi;
+
+ const handleJellyseerrUrlBlur = useCallback(() => {
+ const url = jellyseerrServerUrl.trim();
+ updateSettings({ jellyseerrServerUrl: url || undefined });
+ }, [jellyseerrServerUrl, updateSettings]);
+
+ const jellyseerrLoginMutation = useMutation({
+ mutationFn: async () => {
+ const url = jellyseerrServerUrl.trim();
+ if (!url) throw new Error("Missing server url");
+ if (!user?.Name) throw new Error("Missing user info");
+ const tempApi = new JellyseerrApi(url);
+ const testResult = await tempApi.test();
+ if (!testResult.isValid) throw new Error("Invalid server url");
+ return tempApi.login(user.Name, jellyseerrPassword);
+ },
+ onSuccess: (loggedInUser) => {
+ setJellyseerrUser(loggedInUser);
+ updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
+ },
+ onError: () => {
+ toast.error(t("jellyseerr.failed_to_login"));
+ },
+ onSettled: () => {
+ setJellyseerrPassword("");
+ },
+ });
+
+ const handleDisconnectJellyseerr = useCallback(() => {
+ clearAllJellyseerData();
+ setJellyseerrServerUrl("");
+ setJellyseerrPassword("");
+ }, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -877,6 +924,72 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
+ {/* seerr Section */}
+
+
+ {t("home.settings.plugins.jellyseerr.server_url_hint")}
+
+
+ {!isJellyseerrConnected && !isJellyseerrLocked && (
+ <>
+
+ jellyseerrLoginMutation.mutate()}
+ disabled={jellyseerrLoginMutation.isPending}
+ />
+ >
+ )}
+
+ {isJellyseerrConnected && !isJellyseerrLocked && (
+
+ )}
+
{/* Storage Section */}
{
+ if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
+ if (jellyseerrAlertedRef.current) return;
+ jellyseerrAlertedRef.current = true;
+ Alert.alert(
+ t("jellyseerr.connect_to_jellyseerr"),
+ t("jellyseerr.connect_in_settings"),
+ );
+ }, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
+
+ // Validate the Jellyseerr session when switching to Discover; warn if expired.
+ useEffect(() => {
+ if (
+ searchType !== "Discover" ||
+ !jellyseerrApi ||
+ !settings?.jellyseerrServerUrl
+ )
+ return;
+ validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
+ if (status.valid) return;
+ Alert.alert(
+ t("jellyseerr.session_expired"),
+ t("jellyseerr.session_expired_connect_again"),
+ );
+ });
+ }, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
+
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState(
JellyseerrSearchSort[
diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts
index 4ae918d8..0c53c1cb 100644
--- a/hooks/useJellyseerr.ts
+++ b/hooks/useJellyseerr.ts
@@ -70,6 +70,35 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES);
};
+export type JellyseerrSessionStatus =
+ | { valid: true }
+ | { valid: false; reason: "no_session" | "expired" };
+
+/**
+ * Checks whether the persisted Jellyseerr session (user + cookies) is still
+ * valid by hitting the server status endpoint. Clears local session data if the
+ * request fails (expired/revoked cookie).
+ */
+export async function validateJellyseerrSession(
+ serverUrl: string,
+): Promise {
+ const user = storage.get(JELLYSEERR_USER);
+ const cookies = storage.get(JELLYSEERR_COOKIES);
+
+ if (!user || !cookies) {
+ return { valid: false, reason: "no_session" };
+ }
+
+ try {
+ const api = new JellyseerrApi(serverUrl);
+ await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
+ return { valid: true };
+ } catch {
+ clearJellyseerrStorageData();
+ return { valid: false, reason: "expired" };
+ }
+}
+
export enum Endpoints {
STATUS = "/status",
API_V1 = "/api/v1",
@@ -450,7 +479,8 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData();
setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined });
- }, []);
+ queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
+ }, [queryClient]);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
diff --git a/translations/en.json b/translations/en.json
index 3c4271b1..acf13a36 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -505,7 +505,11 @@
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
- "seeAll": "See all"
+ "seeAll": "See all",
+ "connect": "Connect",
+ "connecting": "Connecting…",
+ "connected": "Connected",
+ "not_connected": "Not connected"
},
"search": {
"search": "Search...",
@@ -732,6 +736,10 @@
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to log in",
+ "connect_to_jellyseerr": "Connect to Jellyseerr",
+ "connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
+ "session_expired": "Session expired",
+ "session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
"cast": "Cast",
"details": "Details",
"status": "Status",
diff --git a/translations/sv.json b/translations/sv.json
index b36c4dc0..72a03372 100644
--- a/translations/sv.json
+++ b/translations/sv.json
@@ -505,7 +505,11 @@
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
- "seeAll": "See all"
+ "seeAll": "See all",
+ "connect": "Anslut",
+ "connecting": "Ansluter…",
+ "connected": "Ansluten",
+ "not_connected": "Inte ansluten"
},
"search": {
"search": "Sök...",
@@ -732,6 +736,10 @@
"request_button": "Önska",
"are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?",
"failed_to_login": "Inloggningen Misslyckades",
+ "connect_to_jellyseerr": "Anslut till Jellyseerr",
+ "connect_in_settings": "Jellyseerr är tillgängligt. Anslut i Inställningar för att aktivera förfrågningsfunktioner.",
+ "session_expired": "Sessionen har gått ut",
+ "session_expired_connect_again": "Din Jellyseerr-session har gått ut. Anslut igen i Inställningar.",
"cast": "Roller",
"details": "Detaljer",
"status": "Status",