mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-02 02:22:51 +01:00
fix(jellyseerr): address PR review feedback on TV connect flow
- validateJellyseerrSession: hit the authenticated /auth/me endpoint instead of the public /status, and only report "expired" on 401/403. Transient failures (offline, 5xx) are treated as valid so a flaky network no longer logs the user out. The helper no longer mutates persisted state. - SearchPage: clear the stale "connected" state via clearAllJellyseerData on an expired session, gate the Discover validation effect behind isFocused, cancel in-flight validation on unmount, and reset the connect prompt when the tab loses focus so it can show again. - settings.tv (seerr): sync the local server-URL field with settings so a late-arriving stored/plugin-locked URL isn't shown stale or overwritten. - useJellyseerr: add setJellyseerrUser/updateSettings to clearAllJellyseerData deps to avoid a stale closure. - TVJellyseerrSearchResults: scale poster/avatar dimensions and use the scaled vertical padding (per review). - translations: drop manual sv.json keys (Crowdin syncs from en.json). Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
This commit is contained in:
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Directory, Paths } from "expo-file-system";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -70,6 +70,13 @@ export default function SettingsTV() {
|
||||
);
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
||||
|
||||
// Settings load storage + plugin overrides after mount, and a plugin can lock
|
||||
// the URL at runtime. Keep the local field in sync with the effective value
|
||||
// so it never shows stale/empty text or overwrites a locked URL on blur.
|
||||
useEffect(() => {
|
||||
setJellyseerrServerUrl(settings.jellyseerrServerUrl || "");
|
||||
}, [settings.jellyseerrServerUrl]);
|
||||
|
||||
const isJellyseerrLocked =
|
||||
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||
const isJellyseerrConnected = !!jellyseerrApi;
|
||||
|
||||
@@ -122,13 +122,19 @@ export default function SearchPage() {
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
const { settings } = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { jellyseerrApi, clearAllJellyseerData } = 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).
|
||||
// session exists yet (once per focus, and only while the tab is focused).
|
||||
const jellyseerrAlertedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
||||
// Reset when the tab loses focus so the prompt can show again next time
|
||||
// (e.g. after the user disconnects and returns).
|
||||
if (!isFocused) {
|
||||
jellyseerrAlertedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
||||
if (jellyseerrAlertedRef.current) return;
|
||||
jellyseerrAlertedRef.current = true;
|
||||
Alert.alert(
|
||||
@@ -137,22 +143,36 @@ export default function SearchPage() {
|
||||
);
|
||||
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
|
||||
|
||||
// Validate the Jellyseerr session when switching to Discover; warn if expired.
|
||||
// Validate the Jellyseerr session when switching to Discover; if the session
|
||||
// has expired, clear the stale "connected" state and prompt to reconnect.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFocused ||
|
||||
searchType !== "Discover" ||
|
||||
!jellyseerrApi ||
|
||||
!settings?.jellyseerrServerUrl
|
||||
)
|
||||
return;
|
||||
let cancelled = false;
|
||||
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
||||
if (status.valid) return;
|
||||
if (cancelled || status.valid || status.reason !== "expired") return;
|
||||
clearAllJellyseerData();
|
||||
Alert.alert(
|
||||
t("jellyseerr.session_expired"),
|
||||
t("jellyseerr.session_expired_connect_again"),
|
||||
);
|
||||
});
|
||||
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isFocused,
|
||||
searchType,
|
||||
jellyseerrApi,
|
||||
settings?.jellyseerrServerUrl,
|
||||
clearAllJellyseerData,
|
||||
t,
|
||||
]);
|
||||
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
|
||||
@@ -14,8 +14,7 @@ import type {
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
const SCALE_PADDING = 20;
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
interface TVJellyseerrPosterProps {
|
||||
item: MovieResult | TvResult;
|
||||
@@ -40,6 +39,8 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
const title = getTitle(item);
|
||||
const year = getYear(item);
|
||||
|
||||
const posterWidth = scaleSize(210);
|
||||
|
||||
const isInLibrary =
|
||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||
@@ -55,7 +56,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
@@ -65,7 +66,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
@@ -158,13 +159,16 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||
: null;
|
||||
|
||||
const containerWidth = scaleSize(160);
|
||||
const avatarSize = scaleSize(140);
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 160,
|
||||
width: containerWidth,
|
||||
alignItems: "center",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
@@ -175,9 +179,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
borderRadius: avatarSize / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
@@ -257,7 +261,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
@@ -310,7 +314,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
@@ -363,7 +367,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
|
||||
@@ -76,8 +76,13 @@ export type JellyseerrSessionStatus =
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* valid by hitting an authenticated endpoint (`/auth/me`).
|
||||
*
|
||||
* Returns `expired` only when the server actually rejects the session
|
||||
* (HTTP 401/403). Transient failures (network down, server unreachable, 5xx)
|
||||
* are treated as `valid` so a flaky connection doesn't log the user out. This
|
||||
* helper does not mutate persisted state — the caller decides whether to clear
|
||||
* the session (e.g. via `clearAllJellyseerData`).
|
||||
*/
|
||||
export async function validateJellyseerrSession(
|
||||
serverUrl: string,
|
||||
@@ -91,11 +96,16 @@ export async function validateJellyseerrSession(
|
||||
|
||||
try {
|
||||
const api = new JellyseerrApi(serverUrl);
|
||||
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
|
||||
await api.axios.get(Endpoints.API_V1 + Endpoints.AUTH_ME);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
const status = (error as AxiosError)?.response?.status;
|
||||
// Only an auth rejection means the session is gone. Anything else
|
||||
// (offline, DNS failure, server error) should not be reported as expired.
|
||||
if (status === 401 || status === 403) {
|
||||
return { valid: false, reason: "expired" };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch {
|
||||
clearJellyseerrStorageData();
|
||||
return { valid: false, reason: "expired" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +133,7 @@ export enum Endpoints {
|
||||
DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
|
||||
DISCOVER_MOVIES_STUDIO = `${DISCOVER}${MOVIE}s${STUDIO}`,
|
||||
AUTH_JELLYFIN = "/auth/jellyfin",
|
||||
AUTH_ME = "/auth/me",
|
||||
}
|
||||
|
||||
export type DiscoverEndpoint =
|
||||
@@ -480,7 +491,7 @@ export const useJellyseerr = () => {
|
||||
setJellyseerrUser(undefined);
|
||||
updateSettings({ jellyseerrServerUrl: undefined });
|
||||
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
|
||||
}, [queryClient]);
|
||||
}, [queryClient, setJellyseerrUser, updateSettings]);
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||
|
||||
@@ -505,11 +505,7 @@
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all",
|
||||
"connect": "Anslut",
|
||||
"connecting": "Ansluter…",
|
||||
"connected": "Ansluten",
|
||||
"not_connected": "Inte ansluten"
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Sök...",
|
||||
@@ -736,10 +732,6 @@
|
||||
"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