From 006a45a84a3cf375cfc7428f1661d9bae0c75a0e Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 21:32:22 -0700 Subject: [PATCH] add support for multiple backends --- example.env | 3 + public/config.js | 2 +- src/assets/locales/en.json | 22 +- src/components/form/BackendSelector.tsx | 223 ++++++++++++++++++ .../player/atoms/WatchPartyStatus.tsx | 8 + .../player/atoms/settings/WatchPartyView.tsx | 12 +- src/hooks/auth/useBackendUrl.ts | 7 +- src/pages/Login.tsx | 82 ++++++- src/pages/Register.tsx | 58 ++++- src/pages/Settings.tsx | 63 ++++- src/pages/parts/auth/TrustBackendPart.tsx | 4 +- src/pages/parts/settings/ConnectionsPart.tsx | 105 +++++++-- src/setup/config.ts | 20 +- 13 files changed, 565 insertions(+), 44 deletions(-) create mode 100644 src/components/form/BackendSelector.tsx diff --git a/example.env b/example.env index 1f28ec6b..39a15ab5 100644 --- a/example.env +++ b/example.env @@ -9,3 +9,6 @@ VITE_M3U8_PROXY_URL=... # make sure the domain does NOT have a slash at the end VITE_APP_DOMAIN=http://localhost:5173 + +# Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com") +VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com diff --git a/public/config.js b/public/config.js index 7d3c39e4..7c5e57c6 100644 --- a/public/config.js +++ b/public/config.js @@ -12,7 +12,7 @@ window.__CONFIG__ = { // Whether to disable hash-based routing, leave this as false if you don't know what this is VITE_NORMAL_ROUTER: true, - // The backend URL to communicate with + // The backend URL(s) to communicate with - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com") VITE_BACKEND_URL: null, // A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-" and "movie-" diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index dc2236a3..b762fe3a 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -180,6 +180,16 @@ "title": "Account information" } }, + "backendSelection": { + "title": "Select Account Server", + "description": "Choose which backend server to connect to", + "customBackend": "Custom Backend", + "customBackendPlaceholder": "https://", + "confirm": "Confirm", + "cancel": "Cancel", + "active": "Active", + "selecting": "Selecting..." + }, "trust": { "failed": { "text": "Did you configure it correctly?", @@ -1116,8 +1126,14 @@ "connections": { "server": { "description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.", - "label": "Custom server", + "label": "Backend Server", "urlLabel": "Custom server URL", + "selectBackend": "Select Backend Server", + "currentBackend": "Current Backend", + "changeWarning": "Changing backend server will log you out. Continue?", + "confirm": "Log out and change server", + "cancel": "Cancel", + "changeWarningTitle": "Change Backend Server", "migration": { "description": "<0>Migrate my data to a new server.", "link": "Migrate my data" @@ -1378,6 +1394,8 @@ "contentMismatch": "Cannot join watch party: The content does not match the host's content.", "episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.", "validating": "Validating watch party...", - "linkCopied": "Copied!" + "linkCopied": "Copied!", + "backendRequirement": "All users must use the same backend server", + "activeBackend": "Active Backend: {{backend}}" } } diff --git a/src/components/form/BackendSelector.tsx b/src/components/form/BackendSelector.tsx new file mode 100644 index 00000000..8159d5f4 --- /dev/null +++ b/src/components/form/BackendSelector.tsx @@ -0,0 +1,223 @@ +import classNames from "classnames"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { MetaResponse, getBackendMeta } from "@/backend/accounts/meta"; +import { Button } from "@/components/buttons/Button"; +import { Icon, Icons } from "@/components/Icon"; +import { Loading } from "@/components/layout/Loading"; +import { TextInputControl } from "@/components/text-inputs/TextInputControl"; + +interface BackendOption { + url: string; + meta: MetaResponse | null; + loading: boolean; + error: boolean; +} + +interface BackendSelectorProps { + selectedUrl: string | null; + onSelect: (url: string | null) => void; + availableUrls: string[]; + showCustom?: boolean; +} + +function BackendOptionItem({ + option, + isSelected, + onClick, +}: { + option: BackendOption; + isSelected: boolean; + onClick: () => void; +}) { + const { t } = useTranslation(); + const hostname = option.url ? new URL(option.url).hostname : undefined; + + return ( + + ); +} + +export function BackendSelector({ + selectedUrl, + onSelect, + availableUrls, + showCustom = true, +}: BackendSelectorProps) { + const { t } = useTranslation(); + const [customUrl, setCustomUrl] = useState(""); + const [backendOptions, setBackendOptions] = useState([]); + + // Initialize and fetch meta for backend options + useEffect(() => { + const fetchMetas = async () => { + const options: BackendOption[] = availableUrls.map((url) => ({ + url, + meta: null, + loading: true, + error: false, + })); + setBackendOptions(options); + + const promises = options.map(async (option) => { + try { + const meta = await getBackendMeta(option.url); + return { ...option, meta, loading: false, error: false }; + } catch { + return { ...option, meta: null, loading: false, error: true }; + } + }); + const results = await Promise.all(promises); + setBackendOptions(results); + }; + + if (availableUrls.length > 0) { + fetchMetas(); + } + }, [availableUrls]); + + const handleCustomUrlSelect = () => { + if (customUrl.trim()) { + let url = customUrl.trim(); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = `https://${url}`; + } + onSelect(url); + } + }; + + const isCustomUrlSelected = + customUrl && + selectedUrl === customUrl && + !availableUrls.includes(selectedUrl); + + return ( +
+ {backendOptions.length > 0 ? ( +
+ {backendOptions.map((option) => ( + onSelect(option.url)} + /> + ))} +
+ ) : null} + + {showCustom && ( +
+
+
+
+ {isCustomUrlSelected ? ( + + ) : null} +
+
+

+ {t("auth.backendSelection.customBackend")} +

+
+ {isCustomUrlSelected ? ( + + {t("auth.backendSelection.active")} + + ) : null} +
+
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/player/atoms/WatchPartyStatus.tsx b/src/components/player/atoms/WatchPartyStatus.tsx index edcb82c1..6b54e23f 100644 --- a/src/components/player/atoms/WatchPartyStatus.tsx +++ b/src/components/player/atoms/WatchPartyStatus.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; import { Icon, Icons } from "@/components/Icon"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useWatchPartySync } from "@/hooks/useWatchPartySync"; import { useAuthStore } from "@/stores/auth"; import { getProgressPercentage } from "@/stores/progress"; @@ -15,6 +16,8 @@ export function WatchPartyStatus() { const [showNotification, setShowNotification] = useState(false); const [lastUserCount, setLastUserCount] = useState(1); const account = useAuthStore((s) => s.account); + const backendUrl = useBackendUrl(); + const backendHostname = backendUrl ? new URL(backendUrl).hostname : null; const { roomUsers, @@ -70,6 +73,11 @@ export function WatchPartyStatus() { {roomCode} + {backendHostname && ( +
+ {t("watchParty.activeBackend", { backend: backendHostname })} +
+ )}
diff --git a/src/components/player/atoms/settings/WatchPartyView.tsx b/src/components/player/atoms/settings/WatchPartyView.tsx index 78297eb4..1be8350f 100644 --- a/src/components/player/atoms/settings/WatchPartyView.tsx +++ b/src/components/player/atoms/settings/WatchPartyView.tsx @@ -220,7 +220,17 @@ export function WatchPartyView({ id }: { id: string }) { ) : ( <>
-
+
+
+ + {t("watchParty.backendRequirement")} + + + {t("watchParty.activeBackend", { + backend: backendUrl || "Unknown", + })} + +
s.backendUrl); - return backendUrl ?? conf().BACKEND_URL; + const config = conf(); + return ( + backendUrl ?? + config.BACKEND_URL ?? + (config.BACKEND_URLS.length > 0 ? config.BACKEND_URLS[0] : null) + ); } diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 5e1b87c3..179da392 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,20 +1,92 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/buttons/Button"; +import { BackendSelector } from "@/components/form/BackendSelector"; +import { + LargeCard, + LargeCardButtons, + LargeCardText, +} from "@/components/layout/LargeCard"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart"; import { PageTitle } from "@/pages/parts/util/PageTitle"; +import { conf } from "@/setup/config"; +import { useAuthStore } from "@/stores/auth"; export function LoginPage() { const navigate = useNavigate(); + const { t } = useTranslation(); + const [showBackendSelection, setShowBackendSelection] = useState(true); + const [selectedBackendUrl, setSelectedBackendUrl] = useState( + null, + ); + const setBackendUrl = useAuthStore((s) => s.setBackendUrl); + const config = conf(); + const availableBackends = + config.BACKEND_URLS.length > 0 + ? config.BACKEND_URLS + : config.BACKEND_URL + ? [config.BACKEND_URL] + : []; + + // If there's only one backend and user hasn't selected a custom one, auto-select it + const currentBackendUrl = useAuthStore((s) => s.backendUrl); + const defaultBackend = + currentBackendUrl ?? + (availableBackends.length === 1 ? availableBackends[0] : null); + + const handleBackendSelect = (url: string | null) => { + setSelectedBackendUrl(url); + if (url) { + setBackendUrl(url); + } + }; + + const handleContinue = () => { + if (selectedBackendUrl || defaultBackend) { + if (selectedBackendUrl) { + setBackendUrl(selectedBackendUrl); + } else if (defaultBackend) { + setBackendUrl(defaultBackend); + } + setShowBackendSelection(false); + } + }; return ( - { - navigate("/"); - }} - /> + {showBackendSelection && + (availableBackends.length > 1 || !defaultBackend) ? ( + + + {t("auth.backendSelection.description")} + + + + + + + ) : ( + { + navigate("/"); + }} + /> + )} ); } diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index ed2ff3f7..ec08556a 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,8 +1,16 @@ import { useState } from "react"; import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { MetaResponse } from "@/backend/accounts/meta"; +import { Button } from "@/components/buttons/Button"; +import { BackendSelector } from "@/components/form/BackendSelector"; +import { + LargeCard, + LargeCardButtons, + LargeCardText, +} from "@/components/layout/LargeCard"; import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; import { AccountCreatePart, @@ -12,6 +20,8 @@ import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePar import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart"; import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart"; import { PageTitle } from "@/pages/parts/util/PageTitle"; +import { conf } from "@/setup/config"; +import { useAuthStore } from "@/stores/auth"; function CaptchaProvider(props: { siteKey: string | null; @@ -27,17 +37,63 @@ function CaptchaProvider(props: { export function RegisterPage() { const navigate = useNavigate(); - const [step, setStep] = useState(0); + const { t } = useTranslation(); + const [step, setStep] = useState(-1); const [mnemonic, setMnemonic] = useState(null); const [account, setAccount] = useState(null); const [siteKey, setSiteKey] = useState(null); + const [selectedBackendUrl, setSelectedBackendUrl] = useState( + null, + ); + const setBackendUrl = useAuthStore((s) => s.setBackendUrl); + const config = conf(); + const availableBackends = + config.BACKEND_URLS.length > 0 + ? config.BACKEND_URLS + : config.BACKEND_URL + ? [config.BACKEND_URL] + : []; + + const handleBackendSelect = (url: string | null) => { + setSelectedBackendUrl(url); + if (url) { + setBackendUrl(url); + } + }; return ( + {step === -1 ? ( + + + {t("auth.backendSelection.description")} + + + + + + + ) : null} {step === 0 ? ( { setSiteKey( meta.hasCaptcha && meta.captchaClientKey diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c411b894..5a7ac6f8 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -16,9 +16,10 @@ import { Button } from "@/components/buttons/Button"; import { SearchBarInput } from "@/components/form/SearchBar"; import { ThinContainer } from "@/components/layout/ThinContainer"; import { WideContainer } from "@/components/layout/WideContainer"; +import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; import { UserIcons } from "@/components/UserIcon"; import { Divider } from "@/components/utils/Divider"; -import { Heading1 } from "@/components/utils/Text"; +import { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; import { Transition } from "@/components/utils/Transition"; import { useAuth } from "@/hooks/auth/useAuth"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; @@ -168,6 +169,10 @@ export function SettingsPage() { const [searchQuery, setSearchQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState(null); const prevCategoryRef = useRef(null); + const backendChangeModal = useModal("settings-backend-change-confirmation"); + const [pendingBackendChange, setPendingBackendChange] = useState< + string | null + >(null); useEffect(() => { const hash = window.location.hash; @@ -730,25 +735,32 @@ export function SettingsPage() { updateProfile(state.profile.state); } - // when backend url gets changed, log the user out first + // when backend url gets changed, show confirmation and log the user out (only if logged in) if (state.backendUrl.changed) { - await logout(); - let url = state.backendUrl.state; if (url && !url.startsWith("http://") && !url.startsWith("https://")) { url = `https://${url}`; } - + if (account) { + // User is logged in - show confirmation + setPendingBackendChange(url); + backendChangeModal.show(); + return; + } + // User is not logged in - just update without confirmation setBackendUrl(url); } }, [ account, backendUrl, + backendChangeModal, + setPendingBackendChange, + state, + setBackendUrl, setEnableThumbnails, setFebboxKey, setdebridToken, setdebridService, - state, setEnableAutoplay, setEnableSkipCredits, setEnableDiscover, @@ -766,8 +778,6 @@ export function SettingsPage() { updateDeviceName, updateProfile, updateNickname, - logout, - setBackendUrl, setProxyTmdb, setEnableCarouselView, setEnableMinimalCards, @@ -948,6 +958,43 @@ export function SettingsPage() {
+ {account && ( + + + + {t("settings.connections.server.changeWarningTitle")} + + + {t("settings.connections.server.changeWarning")} + +
+ + +
+
+
+ )} ); } diff --git a/src/pages/parts/auth/TrustBackendPart.tsx b/src/pages/parts/auth/TrustBackendPart.tsx index 16c16c66..333f9a27 100644 --- a/src/pages/parts/auth/TrustBackendPart.tsx +++ b/src/pages/parts/auth/TrustBackendPart.tsx @@ -16,12 +16,14 @@ import { MwLink } from "@/components/text/Link"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; interface TrustBackendPartProps { + backendUrl?: string | null; onNext?: (meta: MetaResponse) => void; } export function TrustBackendPart(props: TrustBackendPartProps) { const navigate = useNavigate(); - const backendUrl = useBackendUrl(); + const defaultBackendUrl = useBackendUrl(); + const backendUrl = props.backendUrl ?? defaultBackendUrl; const hostname = useMemo( () => (backendUrl ? new URL(backendUrl).hostname : undefined), [backendUrl], diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index 443b916a..443f03e2 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -9,6 +9,7 @@ import { Trans, useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; import { Toggle } from "@/components/buttons/Toggle"; +import { BackendSelector } from "@/components/form/BackendSelector"; import { Dropdown } from "@/components/form/Dropdown"; import { Icon, Icons } from "@/components/Icon"; import { SettingsCard } from "@/components/layout/SettingsCard"; @@ -184,9 +185,44 @@ function ProxyEdit({ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) { const { t } = useTranslation(); const user = useAuthStore(); + const config = conf(); + const availableBackends = + config.BACKEND_URLS.length > 0 + ? config.BACKEND_URLS + : config.BACKEND_URL + ? [config.BACKEND_URL] + : []; + const currentBackendUrl = + backendUrl ?? (availableBackends.length > 0 ? availableBackends[0] : null); + const [pendingBackendUrl, setPendingBackendUrl] = useState( + currentBackendUrl, + ); + const confirmationModal = useModal("backend-change-confirmation"); + + const handleBackendSelect = (url: string | null) => { + if (!user.account) { + // No account - just update without confirmation + setBackendUrl(url); + setPendingBackendUrl(url); + } else if (url !== currentBackendUrl) { + // User is logged in and changing backend - show confirmation + setPendingBackendUrl(url); + confirmationModal.show(); + } else { + // Same backend - just update + setBackendUrl(url); + setPendingBackendUrl(url); + } + }; + + const handleConfirmChange = () => { + setBackendUrl(pendingBackendUrl); + confirmationModal.hide(); + }; + return ( - -
+ <> +

{t("settings.connections.server.label")} @@ -211,27 +247,50 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {

)}
-
- setBackendUrl((s) => (s === null ? "" : null))} - enabled={backendUrl !== null} - /> -
-
- {backendUrl !== null ? ( - <> - -

- {t("settings.connections.server.urlLabel")} -

- - - ) : null} - + {(availableBackends.length > 0 || currentBackendUrl) && ( + <> + +

+ {t("settings.connections.server.selectBackend")} +

+ {availableBackends.length > 0 ? ( + + ) : ( + + )} + + )} + + {user.account && ( + + + + {t("settings.connections.server.changeWarningTitle")} + + + {t("settings.connections.server.changeWarning")} + +
+ + +
+
+
+ )} + ); } diff --git a/src/setup/config.ts b/src/setup/config.ts index b5d6a618..3f410131 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -49,6 +49,7 @@ export interface RuntimeConfig { PROXY_URLS: string[]; M3U8_PROXY_URLS: string[]; BACKEND_URL: string | null; + BACKEND_URLS: string[]; DISALLOWED_IDS: string[]; CDN_REPLACEMENTS: Array; HAS_ONBOARDING: boolean; @@ -137,7 +138,24 @@ export function conf(): RuntimeConfig { "https://docs.pstream.mov/extension", ), ONBOARDING_PROXY_INSTALL_LINK: getKey("ONBOARDING_PROXY_INSTALL_LINK"), - BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL), + BACKEND_URLS: getKey("BACKEND_URL", BACKEND_URL) + ? getKey("BACKEND_URL", BACKEND_URL) + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0) + : [], + BACKEND_URL: (() => { + const backendUrlValue = getKey("BACKEND_URL", BACKEND_URL); + if (!backendUrlValue) return backendUrlValue; + if (backendUrlValue.includes(",")) { + const urls = backendUrlValue + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + return urls.length > 0 ? urls[0] : backendUrlValue; + } + return backendUrlValue; + })(), TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"), PROXY_URLS: getKey("CORS_PROXY_URL", "") .split(",")