mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 00:34:43 +01:00
Compare commits
11 Commits
view-passw
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a377317710 | ||
|
|
85929c2854 | ||
|
|
214832f81c | ||
|
|
17f7b42728 | ||
|
|
b8c586139f | ||
|
|
4dd5e97971 | ||
|
|
32094fbc9f | ||
|
|
b5917821a0 | ||
|
|
42922cc92b | ||
|
|
0298fb00aa | ||
|
|
e47c863aa4 |
60
.github/copilot-instructions.md
vendored
Normal file
60
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
# Copilot Instructions for Streamyfin
|
||||
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
|
||||
|
||||
## Main Technologies
|
||||
|
||||
- React Native (Expo)
|
||||
- TypeScript
|
||||
- React Query
|
||||
- Jotai (state management)
|
||||
- Jellyfin SDK (TypeScript)
|
||||
- BiomeJS (code formatting/linting)
|
||||
- EAS (Expo Application Services)
|
||||
- Shell scripting (for automation)
|
||||
- GitHub Actions (CI/CD)
|
||||
|
||||
## Code Structure
|
||||
|
||||
- `app/` – Main application code (screens, navigation, etc.)
|
||||
- `components/` – Reusable UI components
|
||||
- `providers/` – Context and API providers (e.g., JellyfinProvider.tsx)
|
||||
- `utils/` – Utility functions and atoms
|
||||
- `assets/` – Images and static assets
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
- `README.md` – Project documentation
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- Use TypeScript for all new code.
|
||||
- Prefer functional React components.
|
||||
- Use hooks for state and side effects.
|
||||
- Use Jotai for global state.
|
||||
- Use React Query for data fetching/caching.
|
||||
- Use BiomeJS for formatting and linting.
|
||||
- Follow the established folder structure for screens/components.
|
||||
|
||||
## API Usage
|
||||
|
||||
- Use the Jellyfin SDK for all server interactions.
|
||||
- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
|
||||
- For navigation, use `expo-router`.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
|
||||
- Example: `feat(player): add Chromecast support`
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- When suggesting code, prefer using existing atoms, hooks, and utility functions.
|
||||
- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
|
||||
- When updating dependencies or scripts, check for compatibility with Expo and EAS.
|
||||
|
||||
---
|
||||
|
||||
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**
|
||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -106,7 +106,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🔄 Mark/Close Stale Issues
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
with:
|
||||
# Global settings
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.35.1",
|
||||
"version": "0.36.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 67,
|
||||
"versionCode": 69,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
))}
|
||||
|
||||
124
components/settings/AddNewServer.tsx
Normal file
124
components/settings/AddNewServer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -308,7 +308,7 @@ export const HomeIndex = () => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
const ss: Section[] = [];
|
||||
for (const [index, section] of settings.home.sections.entries()) {
|
||||
const id = section.items?.title || `section-${index}`;
|
||||
const id = section.title || `section-${index}`;
|
||||
ss.push({
|
||||
title: t(`${id}`),
|
||||
queryKey: ["home", id],
|
||||
|
||||
89
components/settings/ServerSwitcher.tsx
Normal file
89
components/settings/ServerSwitcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.35.1",
|
||||
"channel": "0.36.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.35.1",
|
||||
"channel": "0.36.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.35.1",
|
||||
"channel": "0.36.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -64,7 +70,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.35.1" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.36.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -87,7 +93,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.35.1"`,
|
||||
}, DeviceId="${deviceId}", Version="0.36.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user