From bbef84132b15b2b96a0cc8eba3dd3b6841098706 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 29 Jun 2026 08:47:23 +0200 Subject: [PATCH] feat(tv): add Jellyseerr connect support on TV (incl. Apple TV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing piece for Jellyseerr on TV: a way to configure and connect to a Jellyseerr server from the TV settings screen. The discover and search UI, native tvOS search field, and post-login auto-connect already existed on develop, but there was no TV-side connect/disconnect flow — so seerr could never be enabled on Apple TV. - settings.tv.tsx: new "seerr" section with server URL + password inputs and Connect/Disconnect (respects plugin-locked server URLs) - useJellyseerr: add validateJellyseerrSession(); clear cached search results on disconnect - search: prompt to connect when a server is configured but no session exists, and warn when the session has expired on Discover - translations: add connect/session keys to en + sv All additions are platform-agnostic React Native, so they work on both Apple TV and Android TV. Ported from #1676 (which was 40 commits behind develop and conflicting); the unrelated Android tv-recommendations changes from that PR were intentionally left out. Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 119 ++++++++++++++++++++++- app/(auth)/(tabs)/(search)/index.tsx | 52 +++++++++- hooks/useJellyseerr.ts | 32 +++++- translations/en.json | 10 +- translations/sv.json | 10 +- 5 files changed, 214 insertions(+), 9 deletions(-) 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",