mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-29 17:20:30 +01:00
feat(tv): add Jellyseerr connect support on TV (incl. Apple TV)
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
This commit is contained in:
@@ -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 */}
|
||||
<TVSectionHeader title='seerr' />
|
||||
<Text
|
||||
style={{
|
||||
color: "#9CA3AF",
|
||||
fontSize: typography.callout - 2,
|
||||
marginBottom: 16,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
<TVSettingsTextInput
|
||||
label={t("home.settings.plugins.jellyseerr.server_url")}
|
||||
value={jellyseerrServerUrl}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
onChangeText={setJellyseerrServerUrl}
|
||||
onBlur={handleJellyseerrUrlBlur}
|
||||
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<>
|
||||
<TVSettingsTextInput
|
||||
label={t("home.settings.plugins.jellyseerr.password")}
|
||||
value={jellyseerrPassword}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||
{ username: user?.Name },
|
||||
)}
|
||||
onChangeText={setJellyseerrPassword}
|
||||
secureTextEntry
|
||||
disabled={jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={
|
||||
jellyseerrLoginMutation.isPending
|
||||
? t("common.connecting")
|
||||
: t("common.connect")
|
||||
}
|
||||
value=''
|
||||
onPress={() => jellyseerrLoginMutation.mutate()}
|
||||
disabled={jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TVSettingsRow
|
||||
label={
|
||||
isJellyseerrConnected
|
||||
? t("common.connected")
|
||||
: t("common.not_connected")
|
||||
}
|
||||
value=''
|
||||
showChevron={false}
|
||||
/>
|
||||
{isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<TVSettingsOptionButton
|
||||
label={t(
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||
)}
|
||||
value=''
|
||||
onPress={handleDisconnectJellyseerr}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Storage Section */}
|
||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||
<TVSettingsOptionButton
|
||||
|
||||
@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||
import {
|
||||
useIsFocused,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import {
|
||||
@@ -20,7 +25,13 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
useJellyseerr,
|
||||
validateJellyseerrSession,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -106,8 +120,40 @@ export default function SearchPage() {
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
const { settings } = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
// Prompt the user to connect when a Jellyseerr server is configured but no
|
||||
// session exists yet (only once per focus, and only while the tab is focused).
|
||||
const jellyseerrAlertedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
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>(
|
||||
JellyseerrSearchSort[
|
||||
|
||||
@@ -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<JellyseerrSessionStatus> {
|
||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||
const cookies = storage.get<string[]>(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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user