Compare commits

...

6 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
a377317710 Replace password storage with token-based authentication for server switching
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 20:03:33 +00:00
copilot-swe-agent[bot]
85929c2854 Add 'Add New Server' functionality without requiring logout
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 18:15:50 +00:00
copilot-swe-agent[bot]
214832f81c Implement enhanced server switching with credential persistence and auto-login
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 18:14:44 +00:00
copilot-swe-agent[bot]
17f7b42728 Enhance server switcher with current server filtering and loading states
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 15:24:59 +00:00
copilot-swe-agent[bot]
b8c586139f Add server switching functionality to settings
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2025-09-05 15:21:27 +00:00
copilot-swe-agent[bot]
4dd5e97971 Initial plan 2025-09-05 15:10:15 +00:00
6 changed files with 417 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AddNewServer } from "@/components/settings/AddNewServer";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
@@ -17,6 +18,7 @@ import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { ServerSwitcher } from "@/components/settings/ServerSwitcher";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
@@ -64,6 +66,10 @@ export default function settings() {
<View className='p-4 flex flex-col gap-y-4'>
<UserInfo />
<ServerSwitcher className='mb-4' />
<AddNewServer className='mb-4' />
<QuickConnect className='mb-4' />
<MediaProvider>

View File

@@ -8,6 +8,10 @@ import { ListItem } from "./list/ListItem";
interface Server {
address: string;
serverName?: string;
serverId?: string;
lastUsername?: string;
savedToken?: string;
}
interface PreviousServersListProps {
@@ -26,6 +30,20 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
const { t } = useTranslation();
const getServerDisplayName = (server: Server) => {
if (server.serverName) {
return `${server.serverName}`;
}
return server.address;
};
const getServerSubtitle = (server: Server) => {
if (server.lastUsername) {
return `${server.address}${server.lastUsername}`;
}
return server.address;
};
if (!previousServers.length) return null;
return (
@@ -35,7 +53,9 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
<ListItem
key={s.address}
onPress={() => onServerSelect(s)}
title={s.address}
title={getServerDisplayName(s)}
subtitle={getServerSubtitle(s)}
icon={s.savedToken ? "key" : "server"}
showArrow
/>
))}

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, View, type ViewProps } from "react-native";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Input } from "../common/Input";
import { Button } from "../Button";
interface Props extends ViewProps {}
export const AddNewServer: React.FC<Props> = ({ ...props }) => {
const [showForm, setShowForm] = useState(false);
const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false);
const { addNewServer } = useJellyfin();
const { t } = useTranslation();
const handleAddServer = async () => {
if (!serverUrl.trim()) {
Alert.alert(t("login.error_title"), "Please enter a server URL");
return;
}
setLoading(true);
try {
// Validate URL format
const cleanUrl = serverUrl.trim().replace(/\/$/, "");
// Test connection to the server
const baseUrl = cleanUrl.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
let validUrl: string | null = null;
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
validUrl = `${protocol}://${baseUrl}`;
break;
}
} catch (error) {
// Continue to next protocol
}
}
if (!validUrl) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server")
);
return;
}
// Add the server to the list
await addNewServer({ address: validUrl });
Alert.alert(
"Success",
`Server ${validUrl} has been added to your server list. You can now switch to it from Quick Switch Servers.`
);
setServerUrl("");
setShowForm(false);
} catch (error) {
console.error("Failed to add server:", error);
Alert.alert(
t("login.error_title"),
"Failed to add server. Please try again."
);
} finally {
setLoading(false);
}
};
return (
<View {...props}>
<ListGroup title={t("server.add_new_server")}>
{!showForm ? (
<ListItem
onPress={() => setShowForm(true)}
title="Add Server"
icon="add"
showArrow
/>
) : (
<View className="p-4 space-y-4">
<Input
placeholder={t("server.server_url_placeholder")}
value={serverUrl}
onChangeText={setServerUrl}
keyboardType="url"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<View className="flex-row space-x-2">
<Button
onPress={handleAddServer}
loading={loading}
disabled={loading || !serverUrl.trim()}
className="flex-1"
>
Add Server
</Button>
<Button
onPress={() => {
setShowForm(false);
setServerUrl("");
}}
className="flex-1 bg-neutral-800 border border-neutral-700"
>
Cancel
</Button>
</View>
</View>
)}
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,89 @@
import { useAtom } from "jotai";
import type React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Server {
address: string;
serverName?: string;
serverId?: string;
lastUsername?: string;
savedToken?: string;
}
interface Props extends ViewProps {}
export const ServerSwitcher: React.FC<Props> = ({ ...props }) => {
const [_previousServers] = useMMKVString("previousServers");
const [api] = useAtom(apiAtom);
const [switchingServer, setSwitchingServer] = useState<string | null>(null);
const { switchServer } = useJellyfin();
const { t } = useTranslation();
const previousServers = useMemo(() => {
const servers = JSON.parse(_previousServers || "[]") as Server[];
// Filter out the current server since we don't need to "switch" to it
const currentServer = api?.basePath;
return servers.filter((server) => server.address !== currentServer);
}, [_previousServers, api?.basePath]);
const handleServerSwitch = async (server: Server) => {
try {
setSwitchingServer(server.address);
await switchServer(server);
} catch (error) {
console.error("Failed to switch server:", error);
setSwitchingServer(null);
}
};
const getServerDisplayName = (server: Server) => {
if (server.serverName) {
return `${server.serverName} (${server.address})`;
}
return server.address;
};
const getServerSubtitle = (server: Server) => {
if (server.lastUsername) {
const hasToken = !!server.savedToken;
return hasToken
? `${server.lastUsername} • Auto-login available`
: `Last user: ${server.lastUsername}`;
}
return undefined;
};
if (!previousServers.length) {
return (
<View {...props}>
<ListGroup title={t("server.quick_switch")}>
<ListItem title={t("server.no_previous_servers")} disabled />
</ListGroup>
</View>
);
}
return (
<View {...props}>
<ListGroup title={t("server.quick_switch")}>
{previousServers.map((server) => (
<ListItem
key={server.address}
onPress={() => handleServerSwitch(server)}
title={getServerDisplayName(server)}
subtitle={getServerSubtitle(server)}
icon={server.savedToken ? "key" : "server"}
showArrow
disabled={switchingServer === server.address}
/>
))}
</ListGroup>
</View>
);
};

View File

@@ -30,6 +30,10 @@ import { store } from "@/utils/store";
interface Server {
address: string;
serverName?: string;
serverId?: string;
lastUsername?: string;
savedToken?: string;
}
export const apiAtom = atom<Api | null>(null);
@@ -40,7 +44,9 @@ interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>;
setServer: (server: Server) => Promise<void>;
removeServer: () => void;
login: (username: string, password: string) => Promise<void>;
switchServer: (server: Server) => Promise<void>;
addNewServer: (server: Server) => Promise<void>;
login: (username: string, password: string, saveCredentials?: boolean) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
}
@@ -180,6 +186,21 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!apiInstance?.basePath) throw new Error("Failed to connect");
// Get server info to obtain serverId and serverName
try {
const response = await fetch(
`${server.address}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
const data = await response.json();
server.serverId = data.Id;
server.serverName = data.ServerName;
}
} catch (error) {
console.warn("Could not get server info:", error);
}
setApi(apiInstance);
storage.set("serverUrl", server.address);
},
@@ -215,9 +236,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async ({
username,
password,
saveCredentials = true,
}: {
username: string;
password: string;
saveCredentials?: boolean;
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
@@ -230,6 +253,26 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
// Save token to the current server if requested
if (saveCredentials && api.basePath) {
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as Server[];
const updatedServers = previousServers.map((server) => {
if (server.address === api.basePath) {
return {
...server,
lastUsername: username,
savedToken: auth.data.AccessToken
};
}
return server;
});
storage.set("previousServers", JSON.stringify(updatedServers));
}
const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(
@@ -297,6 +340,112 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
const switchServerMutation = useMutation({
mutationFn: async (server: Server) => {
// Get current server info for comparison
const currentServerId = await getCurrentServerId();
// If switching to same server (different URL), try auto-login with saved token
if (server.serverId && server.serverId === currentServerId && server.savedToken) {
try {
// Create API instance with saved token
const apiInstance = jellyfin?.createApi(server.address, server.savedToken);
if (!apiInstance) throw new Error("Failed to create API instance");
// Validate the token by making an authenticated request
const userApi = getUserApi(apiInstance);
const userResponse = await userApi.getCurrentUser();
if (userResponse.data) {
// Token is valid, update the API and user
setApi(apiInstance);
setUser(userResponse.data);
storage.set("serverUrl", server.address);
storage.set("token", server.savedToken);
storage.set("user", JSON.stringify(userResponse.data));
return;
}
} catch (error) {
console.warn("Saved token is invalid, falling back to manual login:", error);
// Remove invalid token from server
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as Server[];
const updatedServers = previousServers.map((s) => {
if (s.address === server.address) {
const { savedToken, ...serverWithoutToken } = s;
return serverWithoutToken;
}
return s;
});
storage.set("previousServers", JSON.stringify(updatedServers));
}
}
// For different servers or if auto-login fails, do the normal logout → set server flow
await logoutMutation.mutateAsync();
await setServerMutation.mutateAsync(server);
},
onError: (error) => {
console.error("Failed to switch server:", error);
},
});
const addNewServerMutation = useMutation({
mutationFn: async (server: Server) => {
// Add a new server to the list without switching to it
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as Server[];
// Get server info first
try {
const response = await fetch(
`${server.address}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
const data = await response.json();
server.serverId = data.Id;
server.serverName = data.ServerName;
}
} catch (error) {
console.warn("Could not get server info:", error);
}
const updatedServers = [
server,
...previousServers.filter((s: Server) => s.address !== server.address),
];
storage.set(
"previousServers",
JSON.stringify(updatedServers.slice(0, 5)),
);
},
onError: (error) => {
console.error("Failed to add new server:", error);
},
});
const getCurrentServerId = async (): Promise<string | null> => {
if (!api?.basePath) return null;
try {
const response = await fetch(
`${api.basePath}/System/Info/Public`,
{ mode: "cors" }
);
if (response.ok) {
const data = await response.json();
return data.Id;
}
} catch (error) {
console.warn("Could not get current server ID:", error);
}
return null;
};
const [loaded, setLoaded] = useState(false);
const [initialLoaded, setInitialLoaded] = useState(false);
@@ -311,6 +460,23 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!jellyfin) return;
try {
// Migrate any existing savedCredentials to remove them
const previousServers = JSON.parse(
storage.getString("previousServers") || "[]",
) as any[];
if (previousServers.length > 0) {
const migratedServers = previousServers.map((server) => {
if (server.savedCredentials) {
// Remove savedCredentials field for security
const { savedCredentials, ...serverWithoutCredentials } = server;
return serverWithoutCredentials;
}
return server;
});
storage.set("previousServers", JSON.stringify(migratedServers));
}
const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage();
const storedUser = getUserFromStorage();
@@ -340,8 +506,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
discoverServers,
setServer: (server) => setServerMutation.mutateAsync(server),
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password) =>
loginMutation.mutateAsync({ username, password }),
switchServer: (server) => switchServerMutation.mutateAsync(server),
addNewServer: (server) => addNewServerMutation.mutateAsync(server),
login: (username, password, saveCredentials = true) =>
loginMutation.mutateAsync({ username, password, saveCredentials }),
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
};

View File

@@ -32,7 +32,12 @@
"clear_button": "Clear",
"search_for_local_servers": "Search for local servers",
"searching": "Searching...",
"servers": "Servers"
"servers": "Servers",
"quick_switch": "Quick Switch Servers",
"switch_server": "Switch Server",
"no_previous_servers": "No previous servers available",
"add_new_server": "Add New Server",
"auto_login_available": "Auto-login available"
},
"home": {
"no_internet": "No Internet",