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 } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; import { router, 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, useState, } from "react"; import { useTranslation } from "react-i18next"; import { AppState, Platform } from "react-native"; import { getDeviceName } from "react-native-device-info"; import uuid from "react-native-uuid"; 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, addServerToList, deleteAccountCredential, getAccountCredential, hashPIN, migrateToMultiAccount, saveAccountCredential, updateAccountToken, } from "@/utils/secureCredentials"; import { store } from "@/utils/store"; interface Server { address: string; } export const apiAtom = atom(null); export const userAtom = atom(null); export const wsAtom = atom(null); interface LoginOptions { saveAccount?: boolean; securityType?: AccountSecurityType; pinCode?: string; } interface JellyfinContextValue { discoverServers: (url: string) => Promise; setServer: (server: Server) => Promise; removeServer: () => void; login: ( username: string, password: string, serverName?: string, options?: LoginOptions, ) => Promise; logout: () => Promise; initiateQuickConnect: () => Promise; loginWithSavedCredential: ( serverUrl: string, userId: string, ) => Promise; loginWithPassword: ( serverUrl: string, username: string, password: string, ) => Promise; removeSavedCredential: (serverUrl: string, userId: string) => Promise; switchServerUrl: (newUrl: string) => void; } const JellyfinContext = createContext( undefined, ); export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { const [jellyfin, setJellyfin] = useState(undefined); const [deviceId, setDeviceId] = useState(undefined); const { t } = useTranslation(); useEffect(() => { (async () => { const id = getOrSetDeviceId(); const deviceName = await getDeviceName(); setJellyfin( () => new Jellyfin({ clientInfo: { name: "Streamyfin", version: "0.52.0" }, deviceInfo: { name: deviceName, id, }, }), ); setDeviceId(id); })(); }, []); const [api, setApi] = useAtom(apiAtom); const [user, setUser] = useAtom(userAtom); const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); const headers = useMemo(() => { if (!deviceId) return {}; return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" }, DeviceId="${deviceId}", Version="0.52.0"`, }; }, [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 pollQuickConnect = useCallback(async () => { if (!api || !secret) 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; api.accessToken = AccessToken; setUser(User); storage.set("token", AccessToken); storage.set("user", JSON.stringify(User)); return true; } } return false; } catch (error) { if (error instanceof AxiosError && error.response?.status === 400) { setIsPolling(false); setSecret(null); throw new Error("The code has expired. Please try again."); } console.error("Error polling Quick Connect:", error); throw error; } }, [api, secret, headers]); 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 => { const servers = await jellyfin?.discovery.getRecommendedServerCandidates(url); return servers?.map((server) => ({ address: server.address })) || []; }; const setServerMutation = useMutation({ mutationFn: async (server: Server) => { 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 () => { 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, }); } 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_occured_did_you_enter_the_correct_url", ), ); } } throw error; } }, onError: (error) => { console.error("Login failed:", error); }, }); const logoutMutation = useMutation({ mutationFn: async () => { await 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"); setUser(null); setApi(null); setPluginSettings(undefined); await clearAllJellyseerData(); // 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(); // 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)); // Refresh plugin settings await refreshStreamyfinPluginSettings(); } catch (error) { // Token is invalid/expired - remove it if ( axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 403) ) { await deleteAccountCredential(serverUrl, userId); throw new Error(t("server.session_expired")); } throw error; } }, onError: (error) => { console.error("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) { 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 await updateAccountToken( serverUrl, auth.data.User.Id || "", auth.data.AccessToken, ); // 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; 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); } const response = await getUserApi(apiInstance).getCurrentUser(); 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", }); } } } } 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, 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 ( {children} ); }; 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(); useEffect(() => { if (loaded === false) return; const inAuthGroup = segments.length > 1 && segments[0] === "(auth)"; if (!user?.Id && inAuthGroup) { router.replace("/login"); } else if (user?.Id && !inAuthGroup) { 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; }