diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx
index 413e015..037d03e 100644
--- a/app/(tabs)/settings.tsx
+++ b/app/(tabs)/settings.tsx
@@ -21,6 +21,7 @@ import { useServer } from '@/context/ServerContext';
import { useTheme, ThemeColors } from '@/context/ThemeContext';
import { FocusAwareStatusBar } from '@/components/FocusAwareStatusBar';
import { APP_VERSION } from '@/utils/version';
+import { hasFallback } from '@/utils/server';
import { spacing, borderRadius } from '@/constants/spacing';
import { shadows } from '@/constants/shadows';
import { typography } from '@/constants/typography';
@@ -55,7 +56,7 @@ function NavRow({ icon, label, onPress, colors, isLast, iconColor }: NavRowProps
export default function SettingsScreen() {
const { t } = useTranslation();
const router = useRouter();
- const { currentServer, isConnected, disconnect } = useServer();
+ const { currentServer, isConnected, activeEndpoint, disconnect } = useServer();
const { isDark, colors } = useTheme();
const disconnectBadgeBackground = colorThemeManager.hexToRgba(
colorThemeManager.rgbaToHex(colors.error),
@@ -114,6 +115,13 @@ export default function SettingsScreen() {
{currentServer.host}
{currentServer.port != null && currentServer.port > 0 ? `:${currentServer.port}` : ''}
+ {isConnected && hasFallback(currentServer) && activeEndpoint && (
+
+ {activeEndpoint === 'fallback'
+ ? t('server.connectedViaFallback')
+ : t('server.connectedViaPrimary')}
+
+ )}
{isConnected && (
(undefined);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
@@ -194,6 +199,13 @@ App Version: ${APP_VERSION}`;
setBypassAuth(server.bypassAuth || false);
// Preserve existing basePath for backward compatibility
setPreservedBasePath(server.basePath || '/');
+ // Round-trip fallback fields
+ setUseFallback(server.useFallback === true);
+ setFallbackHost(server.fallbackHost || '');
+ const hasFallbackPort = server.fallbackPort != null && server.fallbackPort > 0;
+ setFallbackPort(hasFallbackPort ? server.fallbackPort!.toString() : '');
+ setFallbackUseHttps(server.fallbackUseHttps || false);
+ setPreservedFallbackBasePath(server.fallbackBasePath);
} else {
showToast(t('toast.serverNotFound'), 'error');
router.back();
@@ -223,6 +235,18 @@ App Version: ${APP_VERSION}`;
return;
}
+ const fallbackPortNum = fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined;
+ if (useFallback) {
+ if (!fallbackHost.trim()) {
+ showToast(t('errors.fillFallbackHost'), 'error');
+ return;
+ }
+ if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) {
+ showToast(t('errors.validPort'), 'error');
+ return;
+ }
+ }
+
try {
setSaving(true);
@@ -241,6 +265,11 @@ App Version: ${APP_VERSION}`;
password: bypassAuth ? '' : password.trim(),
useHttps,
bypassAuth,
+ useFallback,
+ fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '',
+ fallbackPort: useFallback ? fallbackPortNum : undefined,
+ fallbackUseHttps: useFallback ? fallbackUseHttps : false,
+ fallbackBasePath: preservedFallbackBasePath, // Preserve existing fallback base path
};
await ServerManager.saveServer(server);
@@ -293,6 +322,18 @@ App Version: ${APP_VERSION}`;
return;
}
+ const fallbackPortNum = fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined;
+ if (useFallback) {
+ if (!fallbackHost.trim()) {
+ showToast(t('errors.fillFallbackHost'), 'error');
+ return;
+ }
+ if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) {
+ showToast(t('errors.validPort'), 'error');
+ return;
+ }
+ }
+
try {
setTesting(true);
testAbortController.current = new AbortController();
@@ -306,11 +347,24 @@ App Version: ${APP_VERSION}`;
password: bypassAuth ? '' : password.trim(),
useHttps,
bypassAuth,
+ useFallback,
+ fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '',
+ fallbackPort: useFallback ? fallbackPortNum : undefined,
+ fallbackUseHttps: useFallback ? fallbackUseHttps : false,
};
const result = await ServerManager.testConnection(server, testAbortController.current.signal);
-
- if (result.success) {
+
+ if (result.primary || result.fallback) {
+ const primaryOk = result.primary?.success;
+ const fallbackOk = result.fallback?.success;
+ const ok = (label: string) => t('server.testEndpointOk', { endpoint: label });
+ const fail = (label: string) => t('server.testEndpointFail', { endpoint: label });
+ const primaryLabel = t('server.endpointPrimary');
+ const fallbackLabel = t('server.endpointFallback');
+ const summary = `${primaryOk ? ok(primaryLabel) : fail(primaryLabel)} · ${fallbackOk ? ok(fallbackLabel) : fail(fallbackLabel)}`;
+ showToast(summary, result.success ? 'success' : 'error');
+ } else if (result.success) {
showToast(t('toast.connectionTestSuccess'), 'success');
} else {
showToast(result.error || t('errors.connectionTestFailed'), 'error');
@@ -435,6 +489,71 @@ App Version: ${APP_VERSION}`;
+ {/* Fallback URL Section */}
+
+ {t('server.fallbackUrl')}
+
+
+
+
+
+ {t('server.useFallback')}
+ {t('server.useFallbackHint')}
+
+
+
+
+ {useFallback && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('server.fallbackUseHttps')}
+
+
+
+ >
+ )}
+
+
+
{/* Authentication Section */}
{!bypassAuth && (
diff --git a/app/server/add.tsx b/app/server/add.tsx
index a550a65..20df362 100644
--- a/app/server/add.tsx
+++ b/app/server/add.tsx
@@ -48,6 +48,10 @@ export default function AddServerScreen() {
const [password, setPassword] = useState('');
const [useHttps, setUseHttps] = useState(false);
const [bypassAuth, setBypassAuth] = useState(false);
+ const [useFallback, setUseFallback] = useState(false);
+ const [fallbackHost, setFallbackHost] = useState('');
+ const [fallbackPort, setFallbackPort] = useState('');
+ const [fallbackUseHttps, setFallbackUseHttps] = useState(false);
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [showHostTooltip, setShowHostTooltip] = useState(false);
@@ -187,6 +191,18 @@ App Version: ${APP_VERSION}`;
return;
}
+ const fallbackPortNum = (fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined);
+ if (useFallback) {
+ if (!fallbackHost.trim()) {
+ showToast(t('errors.fillFallbackHost'), 'error');
+ return;
+ }
+ if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) {
+ showToast(t('errors.validPort'), 'error');
+ return;
+ }
+ }
+
try {
setLoading(true);
@@ -204,6 +220,10 @@ App Version: ${APP_VERSION}`;
password: bypassAuth ? '' : password.trim(),
useHttps,
bypassAuth,
+ useFallback,
+ fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '',
+ fallbackPort: useFallback ? fallbackPortNum : undefined,
+ fallbackUseHttps: useFallback ? fallbackUseHttps : false,
};
await ServerManager.saveServer(server);
@@ -246,6 +266,18 @@ App Version: ${APP_VERSION}`;
return;
}
+ const fallbackPortNum = (fallbackPort.trim() ? parseInt(fallbackPort, 10) : undefined);
+ if (useFallback) {
+ if (!fallbackHost.trim()) {
+ showToast(t('errors.fillFallbackHost'), 'error');
+ return;
+ }
+ if (fallbackPortNum !== undefined && (isNaN(fallbackPortNum) || fallbackPortNum < 1 || fallbackPortNum > 65535)) {
+ showToast(t('errors.validPort'), 'error');
+ return;
+ }
+ }
+
try {
setTesting(true);
testAbortController.current = new AbortController();
@@ -259,11 +291,25 @@ App Version: ${APP_VERSION}`;
password: bypassAuth ? '' : password.trim(),
useHttps,
bypassAuth,
+ useFallback,
+ fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '',
+ fallbackPort: useFallback ? fallbackPortNum : undefined,
+ fallbackUseHttps: useFallback ? fallbackUseHttps : false,
};
const result = await ServerManager.testConnection(server, testAbortController.current.signal);
-
- if (result.success) {
+
+ if (result.primary || result.fallback) {
+ // Fallback was tested too — surface per-endpoint outcome.
+ const primaryOk = result.primary?.success;
+ const fallbackOk = result.fallback?.success;
+ const ok = (label: string) => t('server.testEndpointOk', { endpoint: label });
+ const fail = (label: string) => t('server.testEndpointFail', { endpoint: label });
+ const primaryLabel = t('server.endpointPrimary');
+ const fallbackLabel = t('server.endpointFallback');
+ const summary = `${primaryOk ? ok(primaryLabel) : fail(primaryLabel)} · ${fallbackOk ? ok(fallbackLabel) : fail(fallbackLabel)}`;
+ showToast(summary, result.success ? 'success' : 'error');
+ } else if (result.success) {
showToast(t('toast.connectionTestSuccess'), 'success');
} else {
showToast(result.error || t('errors.connectionTestFailed'), 'error');
@@ -379,6 +425,71 @@ App Version: ${APP_VERSION}`;
+ {/* Fallback URL Section */}
+
+ {t('server.fallbackUrl')}
+
+
+
+
+
+ {t('server.useFallback')}
+ {t('server.useFallbackHint')}
+
+
+
+
+ {useFallback && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('server.fallbackUseHttps')}
+
+
+
+ >
+ )}
+
+
+
{/* Authentication Section */}
{!bypassAuth && (
diff --git a/context/ServerContext.tsx b/context/ServerContext.tsx
index 9029a3f..72d5c8a 100644
--- a/context/ServerContext.tsx
+++ b/context/ServerContext.tsx
@@ -1,14 +1,21 @@
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useMutation } from '@tanstack/react-query';
-import { ServerConfig } from '@/types/api';
+import { ServerConfig, ServerEndpointKind } from '@/types/api';
import { ServerManager } from '@/services/server-manager';
import { apiClient } from '@/services/api/client';
import { storageService } from '@/services/storage';
+import { getActiveEndpoint } from '@/utils/server';
interface ServerContextType {
currentServer: ServerConfig | null;
isConnected: boolean;
isLoading: boolean;
+ /**
+ * Which endpoint of `currentServer` is currently active in `apiClient`.
+ * Null when not connected or when the server has no fallback configured
+ * and the endpoint is unambiguous (callers can treat null as "primary").
+ */
+ activeEndpoint: ServerEndpointKind | null;
connectToServer: (server: ServerConfig) => Promise;
disconnect: () => Promise;
reconnect: () => Promise;
@@ -20,9 +27,21 @@ const ServerContext = createContext(undefined);
export function ServerProvider({ children }: { children: ReactNode }) {
const [currentServer, setCurrentServer] = useState(null);
const [isConnected, setIsConnected] = useState(false);
+ const [activeEndpoint, setActiveEndpoint] = useState(null);
const [initLoading, setInitLoading] = useState(true);
const [reconnecting, setReconnecting] = useState(false);
+ // Derive the active endpoint from the server config + the endpoint the
+ // apiClient ended up on after a (re)connect. Called from each connection
+ // flow rather than via subscription because apiClient doesn't emit events.
+ const refreshActiveEndpoint = useCallback((server: ServerConfig | null, connected: boolean) => {
+ if (!server || !connected) {
+ setActiveEndpoint(null);
+ return;
+ }
+ setActiveEndpoint(getActiveEndpoint(server, apiClient.getServer()));
+ }, []);
+
useEffect(() => {
async function autoConnect() {
try {
@@ -47,28 +66,32 @@ export function ServerProvider({ children }: { children: ReactNode }) {
try {
const connected = await ServerManager.connectToServer(server);
setIsConnected(connected);
+ refreshActiveEndpoint(server, connected);
if (!connected) {
setCurrentServer(null);
apiClient.setServer(null);
}
} catch {
setIsConnected(false);
+ setActiveEndpoint(null);
setCurrentServer(null);
apiClient.setServer(null);
}
} else {
setIsConnected(false);
+ setActiveEndpoint(null);
apiClient.setServer(null);
}
} catch {
setIsConnected(false);
+ setActiveEndpoint(null);
apiClient.setServer(null);
} finally {
setInitLoading(false);
}
}
autoConnect();
- }, []);
+ }, [refreshActiveEndpoint]);
const connectMutation = useMutation({
mutationFn: (server: ServerConfig) => ServerManager.connectToServer(server),
@@ -76,12 +99,15 @@ export function ServerProvider({ children }: { children: ReactNode }) {
if (success) {
setCurrentServer(server);
setIsConnected(true);
+ refreshActiveEndpoint(server, true);
} else {
setIsConnected(false);
+ setActiveEndpoint(null);
}
},
onError: () => {
setIsConnected(false);
+ setActiveEndpoint(null);
},
});
@@ -101,6 +127,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {
onSuccess: () => {
setCurrentServer(null);
setIsConnected(false);
+ setActiveEndpoint(null);
},
});
@@ -113,36 +140,42 @@ export function ServerProvider({ children }: { children: ReactNode }) {
setReconnecting(true);
const success = await ServerManager.reconnect();
setIsConnected(success);
+ refreshActiveEndpoint(currentServer, success);
return success;
} catch {
setIsConnected(false);
+ setActiveEndpoint(null);
return false;
} finally {
setReconnecting(false);
}
- }, []);
+ }, [currentServer, refreshActiveEndpoint]);
const checkAndReconnect = useCallback(async (): Promise => {
if (!currentServer) {
setIsConnected(false);
+ setActiveEndpoint(null);
return false;
}
try {
const success = await ServerManager.reconnect();
setIsConnected(success);
+ refreshActiveEndpoint(currentServer, success);
return success;
} catch {
try {
const reconnected = await ServerManager.connectToServer(currentServer);
setIsConnected(reconnected);
+ refreshActiveEndpoint(currentServer, reconnected);
return reconnected;
} catch {
setIsConnected(false);
+ setActiveEndpoint(null);
return false;
}
}
- }, [currentServer]);
+ }, [currentServer, refreshActiveEndpoint]);
const isLoading =
initLoading || connectMutation.isPending || disconnectMutation.isPending || reconnecting;
@@ -153,6 +186,7 @@ export function ServerProvider({ children }: { children: ReactNode }) {
currentServer,
isConnected,
isLoading,
+ activeEndpoint,
connectToServer,
disconnect,
reconnect,
diff --git a/locales/de/translation.json b/locales/de/translation.json
index 59bcc5c..645f5d8 100644
--- a/locales/de/translation.json
+++ b/locales/de/translation.json
@@ -368,7 +368,8 @@
"username": "Benutzername",
"password": "Passwort",
"trackerUrl": "https://tracker.example.com/announce",
- "enterKbs": "KiB/s eingeben"
+ "enterKbs": "KiB/s eingeben",
+ "fallbackHost": "Fallback-Host oder IP"
},
"actions": {
"resume": "Fortsetzen",
@@ -442,7 +443,17 @@
"deleteServerConfirm": "Remove \"{{name}}\" from your server list?",
"hostAddress": "Host-Adresse",
"port": "Port",
- "gotIt": "Verstanden"
+ "gotIt": "Verstanden",
+ "fallbackUrl": "FALLBACK-URL",
+ "useFallback": "Fallback-URL verwenden",
+ "useFallbackHint": "Diese URL versuchen, wenn die primäre fehlschlägt",
+ "fallbackUseHttps": "Fallback HTTPS",
+ "endpointPrimary": "Primär",
+ "endpointFallback": "Fallback",
+ "testEndpointOk": "{{endpoint}}: OK",
+ "testEndpointFail": "{{endpoint}}: Fehler",
+ "connectedViaPrimary": "Verbunden über Primär",
+ "connectedViaFallback": "Verbunden über Fallback"
},
"torrentDetail": {
"notFound": "Torrent nicht gefunden",
@@ -711,6 +722,7 @@
"fillNameAndHost": "Bitte Server-Name und Host ausfüllen",
"fillUsernamePassword": "Bitte Benutzername und Passwort eingeben oder Auth-Umgehung aktivieren",
"validPort": "Bitte gültige Portnummer eingeben (1-65535)",
+ "fillFallbackHost": "Bitte einen Fallback-Host angeben",
"selectTorrentFile": "Bitte eine .torrent-Datei auswählen",
"enterUrlOrMagnet": "Bitte URL/Magnet-Link eingeben oder .torrent-Datei auswählen",
"enterCategoryName": "Bitte Kategoriename eingeben",
diff --git a/locales/en/translation.json b/locales/en/translation.json
index c0d291e..e543519 100644
--- a/locales/en/translation.json
+++ b/locales/en/translation.json
@@ -388,7 +388,8 @@
"username": "Username",
"password": "Password",
"trackerUrl": "https://tracker.example.com/announce",
- "enterKbs": "Enter KiB/s"
+ "enterKbs": "Enter KiB/s",
+ "fallbackHost": "Fallback host or IP"
},
"actions": {
"resume": "Resume",
@@ -462,7 +463,17 @@
"deleteServerConfirm": "Remove \"{{name}}\" from your server list?",
"hostAddress": "Host Address",
"port": "Port",
- "gotIt": "Got it"
+ "gotIt": "Got it",
+ "fallbackUrl": "FALLBACK URL",
+ "useFallback": "Use fallback URL",
+ "useFallbackHint": "Try this URL if the primary fails",
+ "fallbackUseHttps": "Fallback HTTPS",
+ "endpointPrimary": "Primary",
+ "endpointFallback": "Fallback",
+ "testEndpointOk": "{{endpoint}}: OK",
+ "testEndpointFail": "{{endpoint}}: failed",
+ "connectedViaPrimary": "Connected via primary",
+ "connectedViaFallback": "Connected via fallback"
},
"torrentDetail": {
"notFound": "Torrent not found",
@@ -731,6 +742,7 @@
"fillNameAndHost": "Please fill in server name and host",
"fillUsernamePassword": "Please fill in username and password, or enable bypass authentication",
"validPort": "Please enter a valid port number (1-65535)",
+ "fillFallbackHost": "Please enter a fallback host",
"selectTorrentFile": "Please select a .torrent file",
"enterUrlOrMagnet": "Please enter a URL/magnet link or select a .torrent file",
"enterCategoryName": "Please enter a category name",
diff --git a/locales/es/translation.json b/locales/es/translation.json
index 123bfd3..40196c2 100644
--- a/locales/es/translation.json
+++ b/locales/es/translation.json
@@ -368,7 +368,8 @@
"username": "Usuario",
"password": "Contraseña",
"trackerUrl": "https://tracker.example.com/announce",
- "enterKbs": "Introducir KiB/s"
+ "enterKbs": "Introducir KiB/s",
+ "fallbackHost": "Host o IP de respaldo"
},
"actions": {
"resume": "Reanudar",
@@ -442,7 +443,17 @@
"deleteServerConfirm": "Remove \"{{name}}\" from your server list?",
"hostAddress": "Dirección del host",
"port": "Puerto",
- "gotIt": "Entendido"
+ "gotIt": "Entendido",
+ "fallbackUrl": "URL DE RESPALDO",
+ "useFallback": "Usar URL de respaldo",
+ "useFallbackHint": "Probar esta URL si la principal falla",
+ "fallbackUseHttps": "HTTPS de respaldo",
+ "endpointPrimary": "Principal",
+ "endpointFallback": "Respaldo",
+ "testEndpointOk": "{{endpoint}}: OK",
+ "testEndpointFail": "{{endpoint}}: error",
+ "connectedViaPrimary": "Conectado por principal",
+ "connectedViaFallback": "Conectado por respaldo"
},
"torrentDetail": {
"notFound": "Torrent no encontrado",
@@ -711,6 +722,7 @@
"fillNameAndHost": "Rellena el nombre y host del servidor",
"fillUsernamePassword": "Rellena usuario y contraseña, o activa bypass de autenticación",
"validPort": "Introduce un número de puerto válido (1-65535)",
+ "fillFallbackHost": "Introduce un host de respaldo",
"selectTorrentFile": "Selecciona un archivo .torrent",
"enterUrlOrMagnet": "Introduce una URL/enlace magnético o selecciona un .torrent",
"enterCategoryName": "Introduce un nombre de categoría",
diff --git a/locales/fr/translation.json b/locales/fr/translation.json
index c755553..fa172d2 100644
--- a/locales/fr/translation.json
+++ b/locales/fr/translation.json
@@ -368,7 +368,8 @@
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"trackerUrl": "https://tracker.example.com/announce",
- "enterKbs": "Entrer KiB/s"
+ "enterKbs": "Entrer KiB/s",
+ "fallbackHost": "Hôte ou IP de secours"
},
"actions": {
"resume": "Reprendre",
@@ -442,7 +443,17 @@
"deleteServerConfirm": "Remove \"{{name}}\" from your server list?",
"hostAddress": "Adresse de l'hôte",
"port": "Port",
- "gotIt": "Compris"
+ "gotIt": "Compris",
+ "fallbackUrl": "URL DE SECOURS",
+ "useFallback": "Utiliser une URL de secours",
+ "useFallbackHint": "Essayer cette URL si la principale échoue",
+ "fallbackUseHttps": "HTTPS de secours",
+ "endpointPrimary": "Principal",
+ "endpointFallback": "Secours",
+ "testEndpointOk": "{{endpoint}} : OK",
+ "testEndpointFail": "{{endpoint}} : échec",
+ "connectedViaPrimary": "Connecté via le principal",
+ "connectedViaFallback": "Connecté via le secours"
},
"torrentDetail": {
"notFound": "Torrent introuvable",
@@ -711,6 +722,7 @@
"fillNameAndHost": "Remplissez le nom et l'hôte du serveur",
"fillUsernamePassword": "Remplissez le nom d'utilisateur et le mot de passe, ou activez le contournement d'authentification",
"validPort": "Entrez un numéro de port valide (1-65535)",
+ "fillFallbackHost": "Saisissez un hôte de secours",
"selectTorrentFile": "Sélectionnez un fichier .torrent",
"enterUrlOrMagnet": "Entrez une URL/lien magnétique ou sélectionnez un fichier .torrent",
"enterCategoryName": "Entrez un nom de catégorie",
diff --git a/locales/ru/translation.json b/locales/ru/translation.json
index f05d422..b6f2cb5 100644
--- a/locales/ru/translation.json
+++ b/locales/ru/translation.json
@@ -366,7 +366,8 @@
"username": "Имя пользователя",
"password": "Пароль",
"trackerUrl": "https://tracker.example.com/announce",
- "enterKbs": "КиБ/с"
+ "enterKbs": "КиБ/с",
+ "fallbackHost": "Резервный хост или IP"
},
"actions": {
"resume": "Запустить",
@@ -440,7 +441,17 @@
"deleteServerConfirm": "Удалить «{{name}}» из списка?",
"hostAddress": "Адрес хоста",
"port": "Порт",
- "gotIt": "Понятно"
+ "gotIt": "Понятно",
+ "fallbackUrl": "РЕЗЕРВНЫЙ URL",
+ "useFallback": "Использовать резервный URL",
+ "useFallbackHint": "Пробовать его, если основной недоступен",
+ "fallbackUseHttps": "Резервный HTTPS",
+ "endpointPrimary": "Основной",
+ "endpointFallback": "Резервный",
+ "testEndpointOk": "{{endpoint}}: OK",
+ "testEndpointFail": "{{endpoint}}: ошибка",
+ "connectedViaPrimary": "Подключено через основной",
+ "connectedViaFallback": "Подключено через резервный"
},
"torrentDetail": {
"notFound": "Торрент не найден",
@@ -709,6 +720,7 @@
"fillNameAndHost": "Укажите имя сервера и хост",
"fillUsernamePassword": "Укажите имя пользователя и пароль или включите обход авторизации",
"validPort": "Укажите порт от 1 до 65535",
+ "fillFallbackHost": "Введите резервный хост",
"selectTorrentFile": "Выберите .torrent-файл",
"enterUrlOrMagnet": "Введите URL или magnet либо выберите .torrent-файл",
"enterCategoryName": "Введите имя категории",
diff --git a/locales/zh/translation.json b/locales/zh/translation.json
index 76a7797..ad8b817 100644
--- a/locales/zh/translation.json
+++ b/locales/zh/translation.json
@@ -368,7 +368,8 @@
"username": "用户名",
"password": "密码",
"trackerUrl": "https://tracker.example.com/announce",
- "enterKbs": "输入 KiB/s"
+ "enterKbs": "输入 KiB/s",
+ "fallbackHost": "备用主机或 IP"
},
"actions": {
"resume": "恢复",
@@ -442,7 +443,17 @@
"deleteServerConfirm": "Remove \"{{name}}\" from your server list?",
"hostAddress": "主机地址",
"port": "端口",
- "gotIt": "知道了"
+ "gotIt": "知道了",
+ "fallbackUrl": "备用 URL",
+ "useFallback": "使用备用 URL",
+ "useFallbackHint": "主 URL 失败时尝试备用 URL",
+ "fallbackUseHttps": "备用 HTTPS",
+ "endpointPrimary": "主",
+ "endpointFallback": "备用",
+ "testEndpointOk": "{{endpoint}}:成功",
+ "testEndpointFail": "{{endpoint}}:失败",
+ "connectedViaPrimary": "通过主 URL 连接",
+ "connectedViaFallback": "通过备用 URL 连接"
},
"torrentDetail": {
"notFound": "未找到种子",
@@ -711,6 +722,7 @@
"fillNameAndHost": "请填写服务器名称和主机",
"fillUsernamePassword": "请填写用户名和密码,或启用绕过认证",
"validPort": "请输入有效端口号 (1-65535)",
+ "fillFallbackHost": "请输入备用主机",
"selectTorrentFile": "请选择 .torrent 文件",
"enterUrlOrMagnet": "请输入 URL/磁力链接或选择 .torrent 文件",
"enterCategoryName": "请输入分类名称",
diff --git a/services/server-manager.ts b/services/server-manager.ts
index f6a80ac..36956dc 100644
--- a/services/server-manager.ts
+++ b/services/server-manager.ts
@@ -5,13 +5,30 @@
* Known issues: isNetworkError was duplicated inline 3× (deduplicated in Task 1.6).
*/
import { AxiosError } from 'axios';
-import { ServerConfig } from '@/types/api';
+import { ServerConfig, ServerEndpointKind } from '@/types/api';
+import { hasFallback, resolveServerEndpoint } from '@/utils/server';
import { storageService } from './storage';
import { apiClient } from './api/client';
import { authApi } from './api/auth';
import { applicationApi } from './api/application';
import { clogInfo, clogWarn, clogError } from './connectivity-log';
+/**
+ * Per-endpoint outcome from a connection test. Used to surface granular
+ * primary/fallback feedback in the UI without forcing every caller to handle
+ * a richer shape — the simple primary-only servers still see a single result.
+ */
+export interface EndpointTestResult {
+ success: boolean;
+ error?: string;
+}
+
+export interface ConnectionTestResult extends EndpointTestResult {
+ /** When fallback was attempted, the per-endpoint outcomes. */
+ primary?: EndpointTestResult;
+ fallback?: EndpointTestResult;
+}
+
export function isNetworkError(error: unknown): boolean {
if (error instanceof AxiosError) {
return (
@@ -70,26 +87,71 @@ export class ServerManager {
}
/**
- * Connect to a server (set as current and authenticate)
+ * Connect to a server (set as current and authenticate). When the server
+ * has a fallback endpoint configured, the primary endpoint is attempted
+ * first; if it fails with a network error, the fallback endpoint is tried.
+ * Authentication errors are not retried against the fallback because they
+ * share credentials with the primary endpoint.
*/
static async connectToServer(server: ServerConfig): Promise {
+ const primaryResolved = resolveServerEndpoint(server, 'primary');
+
try {
- clogInfo('CONN', `Connecting to ${server.host}:${server.port || 'default'} (bypassAuth=${server.bypassAuth})`);
- // Set server in API client
- apiClient.setServer(server);
+ const success = await this.connectToEndpoint(server, primaryResolved, 'primary');
+ if (success) return true;
+ // Login returned Fails (auth) — don't try fallback with the same creds.
+ return false;
+ } catch (primaryError: unknown) {
+ if (!hasFallback(server) || !isNetworkError(primaryError)) {
+ // Not eligible for fallback — surface the original error.
+ throw primaryError;
+ }
+
+ const fallbackResolved = resolveServerEndpoint(server, 'fallback');
+ const primaryMessage = primaryError instanceof Error ? primaryError.message : String(primaryError);
+ clogWarn('CONN', `Primary endpoint failed (${primaryMessage}); trying fallback ${fallbackResolved.host}:${fallbackResolved.port || 'default'}`);
+
+ try {
+ const success = await this.connectToEndpoint(server, fallbackResolved, 'fallback');
+ if (success) {
+ clogInfo('CONN', 'Connected via fallback endpoint');
+ return true;
+ }
+ return false;
+ } catch (fallbackError: unknown) {
+ const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
+ clogError('CONN', `Fallback endpoint also failed: ${fallbackMessage}`);
+ // Surface the fallback error since both routes failed; it's the
+ // most recent and usually the most informative.
+ throw fallbackError;
+ }
+ }
+ }
+
+ /**
+ * Internal: attempt a connection against a single resolved endpoint. The
+ * resolved config carries the chosen host/port/useHttps while keeping the
+ * shared id/credentials so cookies and stored IDs remain consistent.
+ */
+ private static async connectToEndpoint(
+ server: ServerConfig,
+ resolved: ServerConfig,
+ endpoint: ServerEndpointKind
+ ): Promise {
+ clogInfo('CONN', `Connecting to ${resolved.host}:${resolved.port || 'default'} via ${endpoint} (bypassAuth=${resolved.bypassAuth})`);
+ apiClient.setServer(resolved);
- // Skip authentication if bypassAuth is enabled
- if (server.bypassAuth) {
- // For bypass auth, still verify the connection works by making a test API call
+ try {
+ if (resolved.bypassAuth) {
try {
await applicationApi.getVersion();
await storageService.setCurrentServerId(server.id);
- clogInfo('CONN', 'Connected successfully (bypass auth)');
+ clogInfo('CONN', `Connected successfully via ${endpoint} (bypass auth)`);
return true;
} catch (error: unknown) {
apiClient.setServer(null);
const message = error instanceof Error ? error.message : String(error);
- clogError('CONN', `Bypass-auth connect failed: ${message}`);
+ clogError('CONN', `Bypass-auth connect failed (${endpoint}): ${message}`);
if (isNetworkError(error)) {
throw error;
}
@@ -97,21 +159,19 @@ export class ServerManager {
}
}
- // Attempt login
- const loginResult = await authApi.login(server.username, server.password);
-
+ const loginResult = await authApi.login(resolved.username, resolved.password);
+
if (loginResult.status === 'Ok') {
- // Verify connection by making a test API call
try {
await applicationApi.getVersion();
await storageService.setCurrentServerId(server.id);
- clogInfo('CONN', 'Connected successfully (authenticated)');
+ clogInfo('CONN', `Connected successfully via ${endpoint} (authenticated)`);
return true;
} catch (error: unknown) {
apiClient.setServer(null);
const message = error instanceof Error ? error.message : String(error);
const axiosErr = error instanceof AxiosError ? error : undefined;
- clogError('CONN', `Post-login API check failed: ${message}`);
+ clogError('CONN', `Post-login API check failed (${endpoint}): ${message}`);
if (isNetworkError(error)) {
throw error;
}
@@ -121,22 +181,15 @@ export class ServerManager {
throw new Error('Failed to connect to server. Please check your settings.');
}
}
-
- // Login failed, clear server
- clogWarn('CONN', 'Login returned Fails — clearing server');
+
+ clogWarn('CONN', `Login returned Fails (${endpoint}) — clearing server`);
apiClient.setServer(null);
return false;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
- clogError('CONN', `connectToServer error: ${message}`);
+ clogError('CONN', `connectToEndpoint(${endpoint}) error: ${message}`);
apiClient.setServer(null);
- if (isNetworkError(error)) {
- throw error;
- }
- if (message.includes('Failed to connect') || message.includes('Authentication failed')) {
- throw error;
- }
- return false;
+ throw error;
}
}
@@ -172,36 +225,62 @@ export class ServerManager {
}
/**
- * Test connection to a server without saving it
- * Returns a result object with success status and error message if failed
+ * Test connection to a server without saving it. When the server has a
+ * fallback endpoint configured, both endpoints are tested and per-endpoint
+ * results are returned alongside the top-level success flag, which is true
+ * when *either* endpoint succeeds.
*/
- static async testConnection(server: ServerConfig, signal?: AbortSignal): Promise<{ success: boolean; error?: string }> {
+ static async testConnection(server: ServerConfig, signal?: AbortSignal): Promise {
+ if (!hasFallback(server)) {
+ // Simple primary-only path — preserves the original shape for callers
+ // that don't need per-endpoint detail.
+ const result = await this.testEndpoint(resolveServerEndpoint(server, 'primary'), signal);
+ return result;
+ }
+
+ const primary = await this.testEndpoint(resolveServerEndpoint(server, 'primary'), signal);
+ if (signal?.aborted) {
+ throw new Error('Test cancelled');
+ }
+ const fallback = await this.testEndpoint(resolveServerEndpoint(server, 'fallback'), signal);
+
+ const success = primary.success || fallback.success;
+ return {
+ success,
+ error: success ? undefined : (fallback.error || primary.error),
+ primary,
+ fallback,
+ };
+ }
+
+ /**
+ * Internal: test a single resolved endpoint and translate errors into the
+ * EndpointTestResult shape. Cancellation propagates so the caller can stop
+ * the whole test sequence in flight.
+ */
+ private static async testEndpoint(resolved: ServerConfig, signal?: AbortSignal): Promise {
const previousServer = apiClient.getServer();
- clogInfo('CONN', `testConnection to ${server.host}:${server.port || 'default'}`);
-
+ clogInfo('CONN', `testEndpoint to ${resolved.host}:${resolved.port || 'default'}`);
+
try {
- // Set server temporarily for testing
- apiClient.setServer(server);
+ apiClient.setServer(resolved);
try {
- if (!server.bypassAuth) {
- // Attempt login with abort signal
- const loginResult = await authApi.login(server.username, server.password, signal);
+ if (!resolved.bypassAuth) {
+ const loginResult = await authApi.login(resolved.username, resolved.password, signal);
if (loginResult.status !== 'Ok') {
- clogWarn('CONN', 'testConnection: auth failed');
+ clogWarn('CONN', 'testEndpoint: auth failed');
return { success: false, error: 'Authentication failed. Please check your username and password.' };
}
}
- // Check if aborted
if (signal?.aborted) {
throw new Error('Test cancelled');
}
- // Verify connection by making a test API call with abort signal
await applicationApi.getVersion(signal);
-
- clogInfo('CONN', 'testConnection succeeded');
+
+ clogInfo('CONN', 'testEndpoint succeeded');
return { success: true };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
@@ -213,19 +292,18 @@ export class ServerManager {
if (axiosErr?.code === 'ERR_CANCELED' || message === 'Test cancelled' || message.includes('cancel')) {
throw error;
}
-
+
if (axiosErr?.response?.status === 403 || axiosErr?.response?.status === 401 || message.includes('Authentication')) {
- clogWarn('CONN', `testConnection failed: auth error (${axiosErr?.response?.status || message})`);
+ clogWarn('CONN', `testEndpoint failed: auth error (${axiosErr?.response?.status || message})`);
return { success: false, error: 'Authentication failed. Please check your credentials.' };
} else if (isNetworkError(error)) {
- clogError('CONN', `testConnection failed: network error (${axiosErr?.code || message})`);
+ clogError('CONN', `testEndpoint failed: network error (${axiosErr?.code || message})`);
return { success: false, error: 'Connection failed. Please check your server address and network connection.' };
} else {
- clogError('CONN', `testConnection failed: ${message}`);
+ clogError('CONN', `testEndpoint failed: ${message}`);
return { success: false, error: message || 'Connection test failed. Please check your settings.' };
}
} finally {
- // Restore previous server state
apiClient.setServer(previousServer);
}
} catch (error: unknown) {
diff --git a/services/storage.ts b/services/storage.ts
index 66a4212..1581558 100644
--- a/services/storage.ts
+++ b/services/storage.ts
@@ -41,6 +41,13 @@ export const storageService = {
password: '', // Don't store password in AsyncStorage
useHttps: s.useHttps || false,
bypassAuth: s.bypassAuth || false,
+ // Fallback endpoint (optional). Only persisted when a fallback host
+ // is configured; absent fields naturally mean fallback is disabled.
+ useFallback: s.useFallback || false,
+ fallbackHost: stripProtocol(s.fallbackHost || ''),
+ fallbackPort: (s.fallbackPort && s.fallbackPort > 0) ? s.fallbackPort : undefined,
+ fallbackUseHttps: s.fallbackUseHttps || false,
+ fallbackBasePath: s.fallbackBasePath || undefined,
}));
await AsyncStorage.setItem(STORAGE_KEYS.SERVERS, JSON.stringify(serversWithoutPasswords));
@@ -66,7 +73,12 @@ export const storageService = {
const serversWithPasswords = await Promise.all(
servers.map(async (server) => {
const password = await SecureStore.getItemAsync(`server_password_${server.id}`) || '';
- return { ...server, password, host: stripProtocol(server.host || '') };
+ return {
+ ...server,
+ password,
+ host: stripProtocol(server.host || ''),
+ fallbackHost: server.fallbackHost ? stripProtocol(server.fallbackHost) : server.fallbackHost,
+ };
})
);
diff --git a/types/api.ts b/types/api.ts
index b50299b..41471c8 100644
--- a/types/api.ts
+++ b/types/api.ts
@@ -17,8 +17,26 @@ export interface ServerConfig {
password: string;
useHttps?: boolean;
bypassAuth?: boolean; // Skip authentication when local network auth is disabled
+
+ /**
+ * When true, the connection flow will attempt the fallback endpoint after the
+ * primary endpoint fails with a network error. Authentication is shared with
+ * the primary endpoint since fallback represents an alternate route to the
+ * same qBittorrent instance (e.g. LAN vs WAN, DDNS vs static IP).
+ */
+ useFallback?: boolean;
+ /** Fallback host (IP or domain). Required when useFallback is true. */
+ fallbackHost?: string;
+ /** Fallback port. Same 1–65535 validation as the primary port. */
+ fallbackPort?: number;
+ /** Whether the fallback endpoint should be reached over HTTPS. */
+ fallbackUseHttps?: boolean;
+ /** Reserved for future fallback base path UI; not surfaced in settings yet. */
+ fallbackBasePath?: string;
}
+export type ServerEndpointKind = 'primary' | 'fallback';
+
// Authentication
export interface LoginResponse {
status: 'Ok' | 'Fails';
diff --git a/utils/server.ts b/utils/server.ts
index b9fb5e7..6906ef4 100644
--- a/utils/server.ts
+++ b/utils/server.ts
@@ -1,4 +1,4 @@
-import { ServerConfig } from '@/types/api';
+import { ServerConfig, ServerEndpointKind } from '@/types/api';
export const AVATAR_PALETTE = [
'#0A84FF', '#30D158', '#FF9F0A', '#FF453A',
@@ -15,3 +15,64 @@ export function serverAddress(server: ServerConfig): string {
const port = server.port && server.port > 0 ? `:${server.port}` : '';
return `${server.host}${port}`;
}
+
+export function hasFallback(server: ServerConfig): boolean {
+ return server.useFallback === true && !!server.fallbackHost?.trim();
+}
+
+export function resolveServerEndpoint(
+ server: ServerConfig,
+ endpoint: ServerEndpointKind
+): ServerConfig {
+ if (endpoint === 'fallback' && hasFallback(server)) {
+ return {
+ ...server,
+ host: server.fallbackHost!.trim(),
+ port: server.fallbackPort,
+ useHttps: server.fallbackUseHttps ?? server.useHttps,
+ basePath: server.fallbackBasePath || server.basePath || '/',
+ };
+ }
+ return {
+ ...server,
+ host: server.host.trim(),
+ basePath: server.basePath || '/',
+ };
+}
+
+export function getServerEndpointLabel(server: ServerConfig, endpoint: ServerEndpointKind): string {
+ return serverAddress(resolveServerEndpoint(server, endpoint));
+}
+
+export function getActiveEndpoint(
+ server: ServerConfig,
+ activeServer: ServerConfig | null
+): ServerEndpointKind | null {
+ if (!activeServer || activeServer.id !== server.id) {
+ return null;
+ }
+
+ const primary = resolveServerEndpoint(server, 'primary');
+ if (
+ activeServer.host === primary.host &&
+ (activeServer.port || undefined) === (primary.port || undefined) &&
+ !!activeServer.useHttps === !!primary.useHttps
+ ) {
+ return 'primary';
+ }
+
+ if (!hasFallback(server)) {
+ return null;
+ }
+
+ const fallback = resolveServerEndpoint(server, 'fallback');
+ if (
+ activeServer.host === fallback.host &&
+ (activeServer.port || undefined) === (fallback.port || undefined) &&
+ !!activeServer.useHttps === !!fallback.useHttps
+ ) {
+ return 'fallback';
+ }
+
+ return null;
+}