mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-13 01:10:22 +01:00
When the server revokes the token (device/session deleted), a 401 can surface from any authenticated request. Nothing cleaned it up: the dead token stayed in storage, every reload re-fired authenticated calls (401 spam, uncaught rejections) and the app lingered half-authenticated. A response interceptor on the authenticated api clears the session once on the first 401 so the app drops cleanly to the login screen. It only attaches when api.accessToken is set, so a wrong-password 401 on the login screen is never treated as session expiry. Saved credentials are kept for quick re-login.
817 lines
26 KiB
TypeScript
817 lines
26 KiB
TypeScript
import "@/augmentations";
|
|
import { type Api, Jellyfin } from "@jellyfin/sdk";
|
|
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import axios, { AxiosError } from "axios";
|
|
import { useSegments } from "expo-router";
|
|
import * as SplashScreen from "expo-splash-screen";
|
|
import { atom, useAtom } from "jotai";
|
|
import type React from "react";
|
|
import {
|
|
createContext,
|
|
type ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AppState, Platform } from "react-native";
|
|
import { getDeviceNameSync } from "react-native-device-info";
|
|
import uuid from "react-native-uuid";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { useInterval } from "@/hooks/useInterval";
|
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
|
import { storage } from "@/utils/mmkv";
|
|
import {
|
|
type AccountSecurityType,
|
|
addAccountToServer,
|
|
addServerToList,
|
|
deleteAccountCredential,
|
|
getAccountCredential,
|
|
hashPIN,
|
|
migrateToMultiAccount,
|
|
saveAccountCredential,
|
|
updateAccountToken,
|
|
} from "@/utils/secureCredentials";
|
|
import { store } from "@/utils/store";
|
|
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
|
import { APP_VERSION } from "@/utils/version";
|
|
|
|
interface Server {
|
|
address: string;
|
|
}
|
|
|
|
const initialApi = (() => {
|
|
try {
|
|
const token = storage.getString("token") || null;
|
|
const serverUrl = storage.getString("serverUrl") || null;
|
|
if (serverUrl && token) {
|
|
const id = getOrSetDeviceId();
|
|
const deviceName = getDeviceNameSync();
|
|
const jellyfinInstance = new Jellyfin({
|
|
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
|
deviceInfo: {
|
|
name: deviceName,
|
|
id,
|
|
},
|
|
});
|
|
return jellyfinInstance.createApi(serverUrl, token);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to initialize API synchronously:", e);
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
const initialUser = (() => {
|
|
try {
|
|
// Only return a stored user if we also have a token. Otherwise the
|
|
// user atom would be populated while the api atom is null (e.g. after
|
|
// a logout that left stale user JSON in storage), which causes
|
|
// useProtectedRoute to keep us inside the (auth) group instead of
|
|
// redirecting to /login.
|
|
const token = storage.getString("token");
|
|
if (!token) return null;
|
|
const userStr = storage.getString("user");
|
|
if (userStr) {
|
|
return JSON.parse(userStr) as UserDto;
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to parse initial user synchronously:", e);
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
export const apiAtom = atom<Api | null>(initialApi);
|
|
export const userAtom = atom<UserDto | null>(initialUser);
|
|
export const wsAtom = atom<WebSocket | null>(null);
|
|
export const cacheVersionAtom = atom<number>(0);
|
|
|
|
interface LoginOptions {
|
|
saveAccount?: boolean;
|
|
securityType?: AccountSecurityType;
|
|
pinCode?: string;
|
|
}
|
|
|
|
interface JellyfinContextValue {
|
|
discoverServers: (url: string) => Promise<Server[]>;
|
|
setServer: (server: Server) => Promise<void>;
|
|
removeServer: () => void;
|
|
login: (
|
|
username: string,
|
|
password: string,
|
|
serverName?: string,
|
|
options?: LoginOptions,
|
|
) => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
initiateQuickConnect: () => Promise<string | undefined>;
|
|
stopQuickConnectPolling: () => void;
|
|
loginWithSavedCredential: (
|
|
serverUrl: string,
|
|
userId: string,
|
|
) => Promise<void>;
|
|
loginWithPassword: (
|
|
serverUrl: string,
|
|
username: string,
|
|
password: string,
|
|
) => Promise<void>;
|
|
removeSavedCredential: (serverUrl: string, userId: string) => Promise<void>;
|
|
switchServerUrl: (newUrl: string) => void;
|
|
}
|
|
|
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const [jellyfin] = useState<Jellyfin | undefined>(() => {
|
|
try {
|
|
const id = getOrSetDeviceId();
|
|
const deviceName = getDeviceNameSync();
|
|
return new Jellyfin({
|
|
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
|
deviceInfo: {
|
|
name: deviceName,
|
|
id,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to initialize Jellyfin synchronously in state:", e);
|
|
return undefined;
|
|
}
|
|
});
|
|
const [deviceId] = useState<string | undefined>(() => {
|
|
try {
|
|
return getOrSetDeviceId();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
const { t } = useTranslation();
|
|
|
|
const [api, setApi] = useAtom(apiAtom);
|
|
const [user, setUser] = useAtom(userAtom);
|
|
const [isPolling, setIsPolling] = useState<boolean>(false);
|
|
const [secret, setSecret] = useState<string | null>(null);
|
|
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
|
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
|
const queryClient = useQueryClient();
|
|
|
|
// --- Session-expiry handling ----------------------------------------------
|
|
// When the server revokes the token (e.g. the device/session is deleted), a
|
|
// 401 can surface from any authenticated request. Without central handling
|
|
// the dead token stays in storage, so every reload re-fires authed calls →
|
|
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
|
|
// state. A single response interceptor on the authenticated api clears the
|
|
// session on the first 401 so the app drops cleanly to the login screen.
|
|
const sessionExpiredRef = useRef(false);
|
|
|
|
const handleSessionExpired = useCallback(() => {
|
|
if (sessionExpiredRef.current) return; // run once per session
|
|
sessionExpiredRef.current = true;
|
|
storage.remove("token");
|
|
storage.remove("user");
|
|
setUser(null);
|
|
setApi(null);
|
|
queryClient.clear();
|
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
|
// Saved credentials are kept so the user can quick-login again.
|
|
}, [setUser, setApi, queryClient]);
|
|
|
|
useEffect(() => {
|
|
// Only guard an authenticated session. A pre-auth api (login screen) keeps
|
|
// its own handling — a wrong-password 401 is not a session expiry.
|
|
if (!api?.accessToken) return;
|
|
sessionExpiredRef.current = false; // re-arm for this fresh session
|
|
const interceptorId = api.axiosInstance.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (error?.response?.status === 401) {
|
|
handleSessionExpired();
|
|
}
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
return () => {
|
|
api.axiosInstance.interceptors.response.eject(interceptorId);
|
|
};
|
|
}, [api, handleSessionExpired]);
|
|
|
|
const headers = useMemo(() => {
|
|
if (!deviceId) return {};
|
|
return {
|
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
|
Platform.OS === "android" ? "Android" : "iOS"
|
|
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
|
|
};
|
|
}, [deviceId]);
|
|
|
|
const initiateQuickConnect = useCallback(async () => {
|
|
if (!api || !deviceId) return;
|
|
try {
|
|
const response = await api.axiosInstance.post(
|
|
`${api.basePath}/QuickConnect/Initiate`,
|
|
null,
|
|
{
|
|
headers,
|
|
},
|
|
);
|
|
if (response?.status === 200) {
|
|
setSecret(response?.data?.Secret);
|
|
setIsPolling(true);
|
|
return response.data?.Code;
|
|
}
|
|
throw new Error("Failed to initiate quick connect");
|
|
} catch (error) {
|
|
console.error(error);
|
|
throw error;
|
|
}
|
|
}, [api, deviceId, headers]);
|
|
|
|
const stopQuickConnectPolling = useCallback(() => {
|
|
setIsPolling(false);
|
|
setSecret(null);
|
|
}, []);
|
|
|
|
const pollQuickConnect = useCallback(async () => {
|
|
if (!api || !secret || !jellyfin) return;
|
|
|
|
try {
|
|
const response = await api.axiosInstance.get(
|
|
`${api.basePath}/QuickConnect/Connect?Secret=${secret}`,
|
|
);
|
|
|
|
if (response.status === 200) {
|
|
if (response.data.Authenticated) {
|
|
setIsPolling(false);
|
|
|
|
const authResponse = await api.axiosInstance.post(
|
|
`${api.basePath}/Users/AuthenticateWithQuickConnect`,
|
|
{
|
|
secret,
|
|
},
|
|
{
|
|
headers,
|
|
},
|
|
);
|
|
|
|
const { AccessToken, User } = authResponse.data;
|
|
setUser(User);
|
|
setApi(jellyfin.createApi(api.basePath, AccessToken));
|
|
storage.set("token", AccessToken);
|
|
storage.set("user", JSON.stringify(User));
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
if (error instanceof AxiosError) {
|
|
if (error.response?.status === 400 || error.response?.status === 404) {
|
|
setIsPolling(false);
|
|
setSecret(null);
|
|
if (error.response?.status === 400) {
|
|
throw new Error("The code has expired. Please try again.");
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
console.error("Error polling Quick Connect:", error);
|
|
throw error;
|
|
}
|
|
}, [api, secret, headers, jellyfin]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
await refreshStreamyfinPluginSettings();
|
|
})();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
store.set(apiAtom, api);
|
|
}, [api]);
|
|
|
|
useInterval(pollQuickConnect, isPolling ? 1000 : null);
|
|
|
|
// Refresh plugin settings when app comes to foreground
|
|
useEffect(() => {
|
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
if (nextAppState === "active") {
|
|
refreshStreamyfinPluginSettings();
|
|
}
|
|
});
|
|
|
|
return () => subscription.remove();
|
|
}, []);
|
|
|
|
const discoverServers = async (url: string): Promise<Server[]> => {
|
|
const servers =
|
|
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
|
return servers?.map((server) => ({ address: server.address })) || [];
|
|
};
|
|
|
|
const setServerMutation = useMutation({
|
|
mutationFn: async (server: Server) => {
|
|
clearTVDiscoverySafely();
|
|
const apiInstance = jellyfin?.createApi(server.address);
|
|
|
|
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
|
|
|
setApi(apiInstance);
|
|
storage.set("serverUrl", server.address);
|
|
},
|
|
onSuccess: async (_, server) => {
|
|
// Add server to the list (will update existing or add new)
|
|
addServerToList(server.address);
|
|
},
|
|
onError: (error) => {
|
|
console.error("Failed to set server:", error);
|
|
},
|
|
});
|
|
|
|
const removeServerMutation = useMutation({
|
|
mutationFn: async () => {
|
|
clearTVDiscoverySafely();
|
|
storage.remove("serverUrl");
|
|
setApi(null);
|
|
},
|
|
onError: (error) => {
|
|
console.error("Failed to remove server:", error);
|
|
},
|
|
});
|
|
|
|
const loginMutation = useMutation({
|
|
mutationFn: async ({
|
|
username,
|
|
password,
|
|
serverName,
|
|
options,
|
|
}: {
|
|
username: string;
|
|
password: string;
|
|
serverName?: string;
|
|
options?: LoginOptions;
|
|
}) => {
|
|
if (!api || !jellyfin) throw new Error("API not initialized");
|
|
|
|
try {
|
|
const auth = await api.authenticateUserByName(username, password);
|
|
|
|
if (auth.data.AccessToken && auth.data.User) {
|
|
setUser(auth.data.User);
|
|
storage.set("user", JSON.stringify(auth.data.User));
|
|
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
|
storage.set("token", auth.data?.AccessToken);
|
|
|
|
// Save credentials to secure storage if requested
|
|
if (api.basePath && options?.saveAccount) {
|
|
const securityType = options.securityType || "none";
|
|
let pinHash: string | undefined;
|
|
|
|
if (securityType === "pin" && options.pinCode) {
|
|
pinHash = await hashPIN(options.pinCode);
|
|
}
|
|
|
|
await saveAccountCredential({
|
|
serverUrl: api.basePath,
|
|
serverName: serverName || "",
|
|
token: auth.data.AccessToken,
|
|
userId: auth.data.User.Id || "",
|
|
username,
|
|
savedAt: Date.now(),
|
|
securityType,
|
|
pinHash,
|
|
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
|
});
|
|
}
|
|
|
|
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
|
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
|
const jellyseerrApi = new JellyseerrApi(
|
|
recentPluginSettings.jellyseerrServerUrl.value,
|
|
);
|
|
await jellyseerrApi.test().then((result) => {
|
|
if (result.isValid && result.requiresPass) {
|
|
jellyseerrApi.login(username, password).then(setJellyseerrUser);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (axios.isAxiosError(error)) {
|
|
switch (error.response?.status) {
|
|
case 401:
|
|
throw new Error(t("login.invalid_username_or_password"));
|
|
case 403:
|
|
throw new Error(
|
|
t("login.user_does_not_have_permission_to_log_in"),
|
|
);
|
|
case 408:
|
|
throw new Error(
|
|
t("login.server_is_taking_too_long_to_respond_try_again_later"),
|
|
);
|
|
case 429:
|
|
throw new Error(
|
|
t("login.server_received_too_many_requests_try_again_later"),
|
|
);
|
|
case 500:
|
|
throw new Error(t("login.there_is_a_server_error"));
|
|
default:
|
|
throw new Error(
|
|
t(
|
|
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
console.error("Login failed:", error);
|
|
},
|
|
});
|
|
|
|
const logoutMutation = useMutation({
|
|
mutationFn: async () => {
|
|
// Fire-and-forget: don't block logout on server cleanup
|
|
api
|
|
?.delete(`/Streamyfin/device/${deviceId}`)
|
|
.then((_r) => writeInfoLog("Deleted expo push token for device"))
|
|
.catch((_e) =>
|
|
writeErrorLog("Failed to delete expo push token for device"),
|
|
);
|
|
|
|
storage.remove("token");
|
|
storage.remove("user");
|
|
clearTVDiscoverySafely();
|
|
setUser(null);
|
|
setApi(null);
|
|
setPluginSettings(undefined);
|
|
await clearAllJellyseerData();
|
|
|
|
// Clear React Query cache to prevent data from previous account lingering
|
|
queryClient.clear();
|
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
|
|
|
// Note: We keep saved credentials for quick switching back
|
|
},
|
|
onError: (error) => {
|
|
console.error("Logout failed:", error);
|
|
},
|
|
});
|
|
|
|
const loginWithSavedCredentialMutation = useMutation({
|
|
mutationFn: async ({
|
|
serverUrl,
|
|
userId,
|
|
}: {
|
|
serverUrl: string;
|
|
userId: string;
|
|
}) => {
|
|
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
|
|
|
const credential = await getAccountCredential(serverUrl, userId);
|
|
if (!credential) {
|
|
throw new Error("No saved credential found");
|
|
}
|
|
|
|
// Create API instance with saved token
|
|
const apiInstance = jellyfin.createApi(serverUrl, credential.token);
|
|
if (!apiInstance) {
|
|
throw new Error("Failed to create API instance");
|
|
}
|
|
|
|
// Validate token by fetching current user
|
|
try {
|
|
const response = await getUserApi(apiInstance).getCurrentUser();
|
|
|
|
// Clear React Query cache to prevent data from previous account lingering
|
|
queryClient.clear();
|
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
|
|
|
// Token is valid, update state
|
|
setApi(apiInstance);
|
|
setUser(response.data);
|
|
storage.set("serverUrl", serverUrl);
|
|
storage.set("token", credential.token);
|
|
storage.set("user", JSON.stringify(response.data));
|
|
|
|
// Update account info (in case user changed their avatar)
|
|
if (response.data.PrimaryImageTag !== credential.primaryImageTag) {
|
|
addAccountToServer(serverUrl, credential.serverName, {
|
|
userId: credential.userId,
|
|
username: credential.username,
|
|
securityType: credential.securityType,
|
|
savedAt: credential.savedAt,
|
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
|
});
|
|
}
|
|
|
|
// Refresh plugin settings
|
|
await refreshStreamyfinPluginSettings();
|
|
} catch (error) {
|
|
// Check for axios error
|
|
if (axios.isAxiosError(error)) {
|
|
// Token is invalid/expired - remove it
|
|
if (
|
|
error.response?.status === 401 ||
|
|
error.response?.status === 403
|
|
) {
|
|
await deleteAccountCredential(serverUrl, userId);
|
|
throw new Error(t("server.session_expired"));
|
|
}
|
|
|
|
// Network error - server not reachable (no response means server didn't respond)
|
|
if (!error.response) {
|
|
throw new Error(t("home.server_unreachable"));
|
|
}
|
|
}
|
|
|
|
// Check for network error by message pattern (fallback detection)
|
|
if (
|
|
error instanceof Error &&
|
|
(error.message.toLowerCase().includes("network") ||
|
|
error.message.toLowerCase().includes("econnrefused") ||
|
|
error.message.toLowerCase().includes("timeout"))
|
|
) {
|
|
throw new Error(t("home.server_unreachable"));
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
|
// server unreachable): the UI surfaces the message, so warn, don't error.
|
|
console.warn("Quick login failed:", error);
|
|
},
|
|
});
|
|
|
|
const loginWithPasswordMutation = useMutation({
|
|
mutationFn: async ({
|
|
serverUrl,
|
|
username,
|
|
password,
|
|
}: {
|
|
serverUrl: string;
|
|
username: string;
|
|
password: string;
|
|
}) => {
|
|
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
|
|
|
// Create API instance for the server
|
|
const apiInstance = jellyfin.createApi(serverUrl);
|
|
if (!apiInstance) {
|
|
throw new Error("Failed to create API instance");
|
|
}
|
|
|
|
// Authenticate with password
|
|
const auth = await apiInstance.authenticateUserByName(username, password);
|
|
|
|
if (auth.data.AccessToken && auth.data.User) {
|
|
// Clear React Query cache to prevent data from previous account lingering
|
|
queryClient.clear();
|
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
|
|
|
setUser(auth.data.User);
|
|
storage.set("user", JSON.stringify(auth.data.User));
|
|
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
|
|
storage.set("serverUrl", serverUrl);
|
|
storage.set("token", auth.data.AccessToken);
|
|
|
|
// Update the saved credential with new token and image tag
|
|
await updateAccountToken(
|
|
serverUrl,
|
|
auth.data.User.Id || "",
|
|
auth.data.AccessToken,
|
|
auth.data.User.PrimaryImageTag ?? undefined,
|
|
);
|
|
|
|
// Refresh plugin settings
|
|
await refreshStreamyfinPluginSettings();
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
console.error("Password login failed:", error);
|
|
throw error;
|
|
},
|
|
});
|
|
|
|
const removeSavedCredentialMutation = useMutation({
|
|
mutationFn: async ({
|
|
serverUrl,
|
|
userId,
|
|
}: {
|
|
serverUrl: string;
|
|
userId: string;
|
|
}) => {
|
|
await deleteAccountCredential(serverUrl, userId);
|
|
},
|
|
onError: (error) => {
|
|
console.error("Failed to remove saved credential:", error);
|
|
},
|
|
});
|
|
|
|
const switchServerUrl = useCallback(
|
|
(newUrl: string) => {
|
|
if (!jellyfin || !api?.accessToken) return;
|
|
|
|
clearTVDiscoverySafely();
|
|
const newApi = jellyfin.createApi(newUrl, api.accessToken);
|
|
setApi(newApi);
|
|
// Note: We don't update storage.set("serverUrl") here
|
|
// because we want to keep the original remote URL as the "primary" URL
|
|
},
|
|
[jellyfin, api?.accessToken],
|
|
);
|
|
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [initialLoaded, setInitialLoaded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (initialLoaded) {
|
|
setLoaded(true);
|
|
}
|
|
}, [initialLoaded]);
|
|
|
|
useEffect(() => {
|
|
const initializeJellyfin = async () => {
|
|
if (!jellyfin) return;
|
|
|
|
try {
|
|
// Run migration to multi-account format (once)
|
|
await migrateToMultiAccount();
|
|
|
|
const token = getTokenFromStorage();
|
|
const serverUrl = getServerUrlFromStorage();
|
|
const storedUser = getUserFromStorage();
|
|
|
|
if (serverUrl && token) {
|
|
const apiInstance = jellyfin.createApi(serverUrl, token);
|
|
setApi(apiInstance);
|
|
|
|
if (storedUser?.Id) {
|
|
setUser(storedUser);
|
|
}
|
|
|
|
// Validate the token and refresh user data in the background. Do NOT
|
|
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
|
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
|
// blocks splash dismissal. The cached storedUser (set above) is enough
|
|
// to render; on success we just refresh it.
|
|
getUserApi(apiInstance)
|
|
.getCurrentUser()
|
|
.then(async (response) => {
|
|
setUser(response.data);
|
|
|
|
// Migrate current session to secure storage if not already saved
|
|
if (storedUser?.Id && storedUser?.Name) {
|
|
const existingCredential = await getAccountCredential(
|
|
serverUrl,
|
|
storedUser.Id,
|
|
);
|
|
if (!existingCredential) {
|
|
await saveAccountCredential({
|
|
serverUrl,
|
|
serverName: "",
|
|
token,
|
|
userId: storedUser.Id,
|
|
username: storedUser.Name,
|
|
savedAt: Date.now(),
|
|
securityType: "none",
|
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
|
});
|
|
} else if (
|
|
response.data.PrimaryImageTag !==
|
|
existingCredential.primaryImageTag
|
|
) {
|
|
// Update image tag if it has changed
|
|
addAccountToServer(serverUrl, existingCredential.serverName, {
|
|
userId: existingCredential.userId,
|
|
username: existingCredential.username,
|
|
securityType: existingCredential.securityType,
|
|
savedAt: existingCredential.savedAt,
|
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
// Expected, handled case (offline, or a token the server rejects —
|
|
// the UI prompts re-login): warn, don't error. Log only
|
|
// status/message — never the raw error (axios errors carry the
|
|
// request config incl. the Authorization header / token).
|
|
console.warn(
|
|
"Background user validation failed:",
|
|
e?.response?.status ?? e?.message ?? "unknown error",
|
|
);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setInitialLoaded(true);
|
|
}
|
|
};
|
|
|
|
initializeJellyfin();
|
|
}, [jellyfin]);
|
|
|
|
const contextValue: JellyfinContextValue = {
|
|
discoverServers,
|
|
setServer: (server) => setServerMutation.mutateAsync(server),
|
|
removeServer: () => removeServerMutation.mutateAsync(),
|
|
login: (username, password, serverName, options) =>
|
|
loginMutation.mutateAsync({ username, password, serverName, options }),
|
|
logout: () => logoutMutation.mutateAsync(),
|
|
initiateQuickConnect,
|
|
stopQuickConnectPolling,
|
|
loginWithSavedCredential: (serverUrl, userId) =>
|
|
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
|
loginWithPassword: (serverUrl, username, password) =>
|
|
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
|
|
removeSavedCredential: (serverUrl, userId) =>
|
|
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
|
switchServerUrl,
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (loaded) {
|
|
SplashScreen.hideAsync();
|
|
}
|
|
}, [loaded]);
|
|
|
|
useProtectedRoute(user, loaded);
|
|
|
|
return (
|
|
<JellyfinContext.Provider value={contextValue}>
|
|
{children}
|
|
</JellyfinContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useJellyfin = (): JellyfinContextValue => {
|
|
const context = useContext(JellyfinContext);
|
|
if (!context)
|
|
throw new Error("useJellyfin must be used within a JellyfinProvider");
|
|
return context;
|
|
};
|
|
|
|
function useProtectedRoute(user: UserDto | null, loaded = false) {
|
|
const segments = useSegments();
|
|
const router = useRouter();
|
|
|
|
useEffect(() => {
|
|
if (loaded === false) return;
|
|
|
|
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
|
const isTopShelfLaunchRoute = segments[0] === "topshelf";
|
|
|
|
if (!user?.Id && inAuthGroup) {
|
|
router.replace("/login");
|
|
} else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) {
|
|
router.replace("/(auth)/(tabs)/(home)/");
|
|
}
|
|
}, [user, segments, loaded]);
|
|
}
|
|
|
|
export function getTokenFromStorage(): string | null {
|
|
return storage.getString("token") || null;
|
|
}
|
|
|
|
export function getUserFromStorage(): UserDto | null {
|
|
const userStr = storage.getString("user");
|
|
if (userStr) {
|
|
try {
|
|
return JSON.parse(userStr) as UserDto;
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getServerUrlFromStorage(): string | null {
|
|
return storage.getString("serverUrl") || null;
|
|
}
|
|
|
|
export function getOrSetDeviceId(): string {
|
|
let deviceId = storage.getString("deviceId");
|
|
|
|
if (!deviceId) {
|
|
deviceId = uuid.v4() as string;
|
|
storage.set("deviceId", deviceId);
|
|
}
|
|
|
|
return deviceId;
|
|
}
|