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 { 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 { Directory, Paths } from "expo-file-system";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
import { APP_LANGUAGES } from "@/i18n";
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
|||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -59,6 +61,51 @@ export default function SettingsTV() {
|
|||||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const queryClient = useQueryClient();
|
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)
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
@@ -877,6 +924,72 @@ export default function SettingsTV() {
|
|||||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
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 */}
|
{/* Storage Section */}
|
||||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Image } from "expo-image";
|
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 { useAtom } from "jotai";
|
||||||
import { orderBy, uniqBy } from "lodash";
|
import { orderBy, uniqBy } from "lodash";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +25,13 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
|||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import {
|
||||||
|
useJellyseerr,
|
||||||
|
validateJellyseerrSession,
|
||||||
|
} from "@/hooks/useJellyseerr";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -106,8 +120,40 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const isFocused = useIsFocused();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
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] =
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
useState<JellyseerrSearchSort>(
|
useState<JellyseerrSearchSort>(
|
||||||
JellyseerrSearchSort[
|
JellyseerrSearchSort[
|
||||||
|
|||||||
@@ -70,6 +70,35 @@ export const clearJellyseerrStorageData = () => {
|
|||||||
storage.remove(JELLYSEERR_COOKIES);
|
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 {
|
export enum Endpoints {
|
||||||
STATUS = "/status",
|
STATUS = "/status",
|
||||||
API_V1 = "/api/v1",
|
API_V1 = "/api/v1",
|
||||||
@@ -450,7 +479,8 @@ export const useJellyseerr = () => {
|
|||||||
clearJellyseerrStorageData();
|
clearJellyseerrStorageData();
|
||||||
setJellyseerrUser(undefined);
|
setJellyseerrUser(undefined);
|
||||||
updateSettings({ jellyseerrServerUrl: undefined });
|
updateSettings({ jellyseerrServerUrl: undefined });
|
||||||
}, []);
|
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
const requestMedia = useCallback(
|
const requestMedia = useCallback(
|
||||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||||
|
|||||||
@@ -505,7 +505,11 @@
|
|||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"seeAll": "See all"
|
"seeAll": "See all",
|
||||||
|
"connect": "Connect",
|
||||||
|
"connecting": "Connecting…",
|
||||||
|
"connected": "Connected",
|
||||||
|
"not_connected": "Not connected"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -732,6 +736,10 @@
|
|||||||
"request_button": "Request",
|
"request_button": "Request",
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
"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",
|
"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",
|
"cast": "Cast",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
|||||||
@@ -505,7 +505,11 @@
|
|||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"seeAll": "See all"
|
"seeAll": "See all",
|
||||||
|
"connect": "Anslut",
|
||||||
|
"connecting": "Ansluter…",
|
||||||
|
"connected": "Ansluten",
|
||||||
|
"not_connected": "Inte ansluten"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Sök...",
|
"search": "Sök...",
|
||||||
@@ -732,6 +736,10 @@
|
|||||||
"request_button": "Önska",
|
"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?",
|
"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",
|
"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",
|
"cast": "Roller",
|
||||||
"details": "Detaljer",
|
"details": "Detaljer",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
|||||||
Reference in New Issue
Block a user