From ecd5daeaa0bfe489789e954162f51f58253eb2eb Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:07:32 -0700 Subject: [PATCH 1/8] remove Provider API logic and OLD turnstile code --- Dockerfile | 2 - docker-compose.yaml | 1 - src/assets/locales/en.json | 6 - src/backend/helpers/providerApi.ts | 144 +----------------- src/backend/providers/fetchers.ts | 8 +- src/components/overlays/OverlayDisplay.tsx | 43 +----- .../player/hooks/useSourceSelection.ts | 61 ++------ src/hooks/useProviderScrape.tsx | 30 +--- src/pages/parts/player/MetaPart.tsx | 47 +----- src/pages/parts/player/ScrapeErrorPart.tsx | 5 +- src/pages/parts/player/ScrapingPart.tsx | 4 +- src/setup/config.ts | 4 - src/stores/turnstile/index.tsx | 48 ------ src/utils/proxyUrls.ts | 8 +- 14 files changed, 27 insertions(+), 384 deletions(-) diff --git a/Dockerfile b/Dockerfile index 90587a93..13e3db4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,6 @@ ARG ONBOARDING_CHROME_EXTENSION_INSTALL_LINK ARG ONBOARDING_PROXY_INSTALL_LINK ARG DISALLOWED_IDS ARG CDN_REPLACEMENTS -ARG TURNSTILE_KEY ARG ALLOW_AUTOPLAY="false" ENV VITE_PWA_ENABLED=${PWA_ENABLED} @@ -39,7 +38,6 @@ ENV VITE_ONBOARDING_CHROME_EXTENSION_INSTALL_LINK=${ONBOARDING_CHROME_EXTENSION_ ENV VITE_ONBOARDING_PROXY_INSTALL_LINK=${ONBOARDING_PROXY_INSTALL_LINK} ENV VITE_DISALLOWED_IDS=${DISALLOWED_IDS} ENV VITE_CDN_REPLACEMENTS=${CDN_REPLACEMENTS} -ENV VITE_TURNSTILE_KEY=${TURNSTILE_KEY} ENV VITE_ALLOW_AUTOPLAY=${ALLOW_AUTOPLAY} COPY . ./ diff --git a/docker-compose.yaml b/docker-compose.yaml index 719b39c5..8147c31d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,6 @@ services: # ONBOARDING_PROXY_INSTALL_LINK: "" # DISALLOWED_IDS: "" # CDN_REPLACEMENTS: "" - # TURNSTILE_KEY: "" ports: - "80:80" restart: unless-stopped diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index a0f93d16..dc2236a3 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -947,12 +947,6 @@ "remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}", "shortRegular": "{{timeWatched}}", "shortRemaining": "-{{timeLeft}}" - }, - "turnstile": { - "description": "Please prove your humanity by completing the quick challenge, this is to keep P-Stream safe.", - "error": "Failed to verify your humanity - stream failed to load. Clear your cache and try again, or switch to a different source (tap the gear).", - "title": "Are You a Robot 🤖?", - "verifyingHumanity": "Verifying your humanity... (^▽^)👍" } }, "support": { diff --git a/src/backend/helpers/providerApi.ts b/src/backend/helpers/providerApi.ts index c0c96db7..ef622777 100644 --- a/src/backend/helpers/providerApi.ts +++ b/src/backend/helpers/providerApi.ts @@ -1,9 +1,6 @@ -import { MetaOutput, NotFoundError, ScrapeMedia } from "@p-stream/providers"; +import { MetaOutput } from "@p-stream/providers"; import { jwtDecode } from "jwt-decode"; -import { mwFetch } from "@/backend/helpers/fetch"; -import { getTurnstileToken, isTurnstileInitialized } from "@/stores/turnstile"; - let metaDataCache: MetaOutput[] | null = null; let token: null | string = null; @@ -31,143 +28,6 @@ function getTokenIfValid(): null | string { return null; } -export async function fetchMetadata(base: string) { - if (metaDataCache) return; - const data = await mwFetch(`${base}/metadata`); - metaDataCache = data.flat(); -} - -function scrapeMediaToQueryMedia(media: ScrapeMedia) { - let extra: Record = {}; - if (media.type === "show") { - extra = { - episodeNumber: media.episode.number.toString(), - episodeTmdbId: media.episode.tmdbId, - seasonNumber: media.season.number.toString(), - seasonTmdbId: media.season.tmdbId, - }; - } - - return { - type: media.type, - releaseYear: media.releaseYear.toString(), - imdbId: media.imdbId, - tmdbId: media.tmdbId, - title: media.title, - ...extra, - }; -} - -function addQueryDataToUrl(url: URL, data: Record) { - Object.entries(data).forEach((entry) => { - if (entry[1]) url.searchParams.set(entry[0], entry[1]); - }); -} - -export function makeProviderUrl(base: string) { - const makeUrl = (p: string) => new URL(`${base}${p}`); - return { - scrapeSource(sourceId: string, media: ScrapeMedia) { - const url = makeUrl("/scrape/source"); - addQueryDataToUrl(url, scrapeMediaToQueryMedia(media)); - addQueryDataToUrl(url, { id: sourceId }); - return url.toString(); - }, - scrapeAll( - media: ScrapeMedia, - sourceOrder?: string[], - embedOrder?: string[], - ) { - const url = makeUrl("/scrape"); - addQueryDataToUrl(url, scrapeMediaToQueryMedia(media)); - if (sourceOrder && sourceOrder.length > 0) { - url.searchParams.set("sourceOrder", sourceOrder.join(",")); - } - if (embedOrder && embedOrder.length > 0) { - url.searchParams.set("embedOrder", embedOrder.join(",")); - } - return url.toString(); - }, - scrapeEmbed(embedId: string, embedUrl: string) { - const url = makeUrl("/scrape/embed"); - addQueryDataToUrl(url, { id: embedId, url: embedUrl }); - return url.toString(); - }, - }; -} - export async function getApiToken(): Promise { - let apiToken = getTokenIfValid(); - if (!apiToken && isTurnstileInitialized()) { - apiToken = `turnstile|${await getTurnstileToken()}`; - } - return apiToken; -} - -function parseEventInput(inp: string): any { - if (inp.length === 0) return {}; - return JSON.parse(inp); -} - -export async function connectServerSideEvents( - url: string, - endEvents: string[], -) { - const apiToken = await getApiToken(); - - // insert token, if its set - const parsedUrl = new URL(url); - if (apiToken) parsedUrl.searchParams.set("token", apiToken); - const eventSource = new EventSource(parsedUrl.toString()); - - let promReject: (reason?: any) => void; - let promResolve: (value: T) => void; - const promise = new Promise((resolve, reject) => { - promResolve = resolve; - promReject = reject; - }); - - endEvents.forEach((evt) => { - eventSource.addEventListener(evt, (e) => { - eventSource.close(); - promResolve(parseEventInput(e.data)); - }); - }); - - eventSource.addEventListener("token", (e) => { - setApiToken(parseEventInput(e.data)); - }); - - eventSource.addEventListener("error", (err: MessageEvent) => { - eventSource.close(); - if (err.data) { - const data = JSON.parse(err.data); - let errObj = new Error("scrape error"); - if (data.name === NotFoundError.name) - errObj = new NotFoundError("Notfound from server"); - Object.assign(errObj, data); - promReject(errObj); - return; - } - - console.error("Failed to connect to SSE", err); - promReject(err); - }); - - eventSource.addEventListener("message", (ev) => { - if (!ev) { - eventSource.close(); - return; - } - setTimeout(() => { - promReject(new Error("SSE closed improperly")); - }, 1000); - }); - - return { - promise: () => promise, - on(event: string, cb: (data: Data) => void) { - eventSource.addEventListener(event, (e) => cb(JSON.parse(e.data))); - }, - }; + return getTokenIfValid(); } diff --git a/src/backend/providers/fetchers.ts b/src/backend/providers/fetchers.ts index cd345577..8e645c66 100644 --- a/src/backend/providers/fetchers.ts +++ b/src/backend/providers/fetchers.ts @@ -6,11 +6,7 @@ import { import { sendExtensionRequest } from "@/backend/extension/messaging"; import { getApiToken, setApiToken } from "@/backend/helpers/providerApi"; -import { - getM3U8ProxyUrls, - getProviderApiUrls, - getProxyUrls, -} from "@/utils/proxyUrls"; +import { getM3U8ProxyUrls, getProxyUrls } from "@/utils/proxyUrls"; import { convertBodyToObject, getBodyTypeFromBody } from "../extension/request"; @@ -28,8 +24,6 @@ function makeLoadbalancedList(getter: () => string[]) { } export const getLoadbalancedProxyUrl = makeLoadbalancedList(getProxyUrls); -export const getLoadbalancedProviderApiUrl = - makeLoadbalancedList(getProviderApiUrls); function getEnabledM3U8ProxyUrls() { const allM3U8ProxyUrls = getM3U8ProxyUrls(); const enabledProxies = localStorage.getItem("m3u8-proxy-enabled"); diff --git a/src/components/overlays/OverlayDisplay.tsx b/src/components/overlays/OverlayDisplay.tsx index e7232b3a..f94f0041 100644 --- a/src/components/overlays/OverlayDisplay.tsx +++ b/src/components/overlays/OverlayDisplay.tsx @@ -2,14 +2,12 @@ import classNames from "classnames"; import FocusTrap from "focus-trap-react"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; import { Transition } from "@/components/utils/Transition"; import { useInternalOverlayRouter, useRouterAnchorUpdate, } from "@/hooks/useOverlayRouter"; -import { TurnstileProvider, getTurnstile } from "@/stores/turnstile"; export interface OverlayProps { id: string; @@ -17,40 +15,6 @@ export interface OverlayProps { darken?: boolean; } -function TurnstileInteractive() { - const { t } = useTranslation(); - const [show, setShow] = useState(false); - - useEffect(() => { - getTurnstile(); - }, []); - - // this may not rerender with different dom structure, must be exactly the same always - return ( -
-
-
-

- {t("player.turnstile.title")} -

-

- {t("player.turnstile.description")} -

-
- setShow(shouldShow)} - /> -
-
- ); -} - export function OverlayDisplay(props: { children: ReactNode }) { const router = useInternalOverlayRouter("hello world :)"); const refRouter = useRef(router); @@ -63,12 +27,7 @@ export function OverlayDisplay(props: { children: ReactNode }) { r.close(); }; }, []); - return ( -
- - {props.children} -
- ); + return
{props.children}
; } export function OverlayPortal(props: { diff --git a/src/components/player/hooks/useSourceSelection.ts b/src/components/player/hooks/useSourceSelection.ts index dde82db0..e2f6f3bc 100644 --- a/src/components/player/hooks/useSourceSelection.ts +++ b/src/components/player/hooks/useSourceSelection.ts @@ -7,15 +7,10 @@ import { useAsyncFn } from "react-use"; import { isExtensionActiveCached } from "@/backend/extension/messaging"; import { prepareStream } from "@/backend/extension/streams"; -import { - connectServerSideEvents, - makeProviderUrl, -} from "@/backend/helpers/providerApi"; import { scrapeSourceOutputToProviderMetric, useReportProviders, } from "@/backend/helpers/report"; -import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; import { getProviders } from "@/backend/providers/providers"; import { convertProviderCaption } from "@/components/player/utils/captions"; import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource"; @@ -60,23 +55,13 @@ export function useEmbedScraping( ); const [request, run] = useAsyncFn(async () => { - const providerApiUrl = getLoadbalancedProviderApiUrl(); let result: EmbedOutput | undefined; if (!meta) return; try { - if (providerApiUrl && !isExtensionActiveCached()) { - const baseUrlMaker = makeProviderUrl(providerApiUrl); - const conn = await connectServerSideEvents( - baseUrlMaker.scrapeEmbed(embedId, url), - ["completed", "noOutput"], - ); - result = await conn.promise(); - } else { - result = await getProviders().runEmbedScraper({ - id: embedId, - url, - }); - } + result = await getProviders().runEmbedScraper({ + id: embedId, + url, + }); } catch (err) { console.error(`Failed to scrape ${embedId}`, err); const notFound = err instanceof NotFoundError; @@ -148,23 +133,13 @@ export function useSourceScraping(sourceId: string | null, routerId: string) { if (!sourceId || !meta) return null; setEmbedId(null); const scrapeMedia = metaToScrapeMedia(meta); - const providerApiUrl = getLoadbalancedProviderApiUrl(); let result: SourcererOutput | undefined; try { - if (providerApiUrl && !isExtensionActiveCached()) { - const baseUrlMaker = makeProviderUrl(providerApiUrl); - const conn = await connectServerSideEvents( - baseUrlMaker.scrapeSource(sourceId, scrapeMedia), - ["completed", "noOutput"], - ); - result = await conn.promise(); - } else { - result = await getProviders().runSourceScraper({ - id: sourceId, - media: scrapeMedia, - }); - } + result = await getProviders().runSourceScraper({ + id: sourceId, + media: scrapeMedia, + }); } catch (err) { console.error(`Failed to scrape ${sourceId}`, err); const notFound = err instanceof NotFoundError; @@ -199,22 +174,10 @@ export function useSourceScraping(sourceId: string | null, routerId: string) { let embedResult: EmbedOutput | undefined; if (!meta) return; try { - if (providerApiUrl && !isExtensionActiveCached()) { - const baseUrlMaker = makeProviderUrl(providerApiUrl); - const conn = await connectServerSideEvents( - baseUrlMaker.scrapeEmbed( - result.embeds[0].embedId, - result.embeds[0].url, - ), - ["completed", "noOutput"], - ); - embedResult = await conn.promise(); - } else { - embedResult = await getProviders().runEmbedScraper({ - id: result.embeds[0].embedId, - url: result.embeds[0].url, - }); - } + embedResult = await getProviders().runEmbedScraper({ + id: result.embeds[0].embedId, + url: result.embeds[0].url, + }); } catch (err) { console.error(`Failed to scrape ${result.embeds[0].embedId}`, err); const notFound = err instanceof NotFoundError; diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx index 0ef0ecea..39b2b561 100644 --- a/src/hooks/useProviderScrape.tsx +++ b/src/hooks/useProviderScrape.tsx @@ -3,12 +3,7 @@ import { RefObject, useCallback, useEffect, useRef, useState } from "react"; import { isExtensionActiveCached } from "@/backend/extension/messaging"; import { prepareStream } from "@/backend/extension/streams"; -import { - connectServerSideEvents, - getCachedMetadata, - makeProviderUrl, -} from "@/backend/helpers/providerApi"; -import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; +import { getCachedMetadata } from "@/backend/helpers/providerApi"; import { getProviders } from "@/backend/providers/providers"; import { getMediaKey } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; @@ -245,29 +240,6 @@ export function useScrape() { ) : undefined; - const providerApiUrl = getLoadbalancedProviderApiUrl(); - if (providerApiUrl && !isExtensionActiveCached()) { - startScrape(); - const baseUrlMaker = makeProviderUrl(providerApiUrl); - const conn = await connectServerSideEvents( - baseUrlMaker.scrapeAll( - media, - filteredSourceOrder, - filteredEmbedOrder, - ), - ["completed", "noOutput"], - ); - conn.on("init", initEvent); - conn.on("start", startEvent); - conn.on("update", updateEvent); - conn.on("discoverEmbeds", discoverEmbedsEvent); - const sseOutput = await conn.promise(); - if (sseOutput && isExtensionActiveCached()) - await prepareStream(sseOutput.stream); - - return getResult(sseOutput === "" ? null : sseOutput); - } - startScrape(); const providers = getProviders(); const output = await providers.runAll({ diff --git a/src/pages/parts/player/MetaPart.tsx b/src/pages/parts/player/MetaPart.tsx index 67228607..c4c77e2e 100644 --- a/src/pages/parts/player/MetaPart.tsx +++ b/src/pages/parts/player/MetaPart.tsx @@ -5,14 +5,10 @@ import type { AsyncReturnType } from "type-fest"; import { isAllowedExtensionVersion } from "@/backend/extension/compatibility"; import { extensionInfo, sendPage } from "@/backend/extension/messaging"; -import { - fetchMetadata, - setCachedMetadata, -} from "@/backend/helpers/providerApi"; +import { setCachedMetadata } from "@/backend/helpers/providerApi"; import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; import { decodeTMDBId } from "@/backend/metadata/tmdb"; import { MWMediaType } from "@/backend/metadata/types/mw"; -import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; import { getProviders } from "@/backend/providers/providers"; import { Button } from "@/components/buttons/Button"; import { Icons } from "@/components/Icon"; @@ -52,20 +48,11 @@ export function MetaPart(props: MetaPartProps) { if (!info.hasPermission) throw new Error("extension-no-permission"); } - // use api metadata or providers metadata - const providerApiUrl = getLoadbalancedProviderApiUrl(); - if (providerApiUrl && !isValidExtension) { - try { - await fetchMetadata(providerApiUrl); - } catch (err) { - throw new Error("failed-api-metadata"); - } - } else { - setCachedMetadata([ - ...getProviders().listSources(), - ...getProviders().listEmbeds(), - ]); - } + // use providers metadata + setCachedMetadata([ + ...getProviders().listSources(), + ...getProviders().listEmbeds(), + ]); // get media meta data let data: ReturnType = null; @@ -160,28 +147,6 @@ export function MetaPart(props: MetaPartProps) { ); } - if (error && error.message === "failed-api-metadata") { - return ( - - - - {t("player.metadata.failed.badge")} - - {t("player.metadata.api.text")} - {t("player.metadata.api.title")} - - - - ); - } - if (error) { return ( diff --git a/src/pages/parts/player/ScrapeErrorPart.tsx b/src/pages/parts/player/ScrapeErrorPart.tsx index 715dee8e..4a4d6f2d 100644 --- a/src/pages/parts/player/ScrapeErrorPart.tsx +++ b/src/pages/parts/player/ScrapeErrorPart.tsx @@ -19,7 +19,6 @@ import { useOnboardingStore } from "@/stores/onboarding"; import { usePreferencesStore } from "@/stores/preferences"; import { getExtensionState } from "@/utils/extension"; import type { ExtensionStatus } from "@/utils/extension"; -import { getProviderApiUrls } from "@/utils/proxyUrls"; import { ErrorCardInModal } from "../errors/ErrorCard"; @@ -42,9 +41,7 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) { const error = useMemo(() => { const data = props.data; let str = ""; - const apiUrls = getProviderApiUrls(); - str += `URL - ${location.pathname}\n`; - str += `API - ${apiUrls.length > 0}\n\n`; + str += `URL - ${location.pathname}\n\n`; Object.values(data.sources).forEach((v) => { str += `${v.id}: ${v.status}\n`; if (v.reason) str += `${v.reason}\n`; diff --git a/src/pages/parts/player/ScrapingPart.tsx b/src/pages/parts/player/ScrapingPart.tsx index 68f572b5..20df4309 100644 --- a/src/pages/parts/player/ScrapingPart.tsx +++ b/src/pages/parts/player/ScrapingPart.tsx @@ -96,7 +96,7 @@ export function ScrapingPart(props: ScrapingProps) { currentProviderIndex = sourceOrder.length - 1; if (failedStartScrape) - return {t("player.turnstile.error")}; + return {t("player.scraping.items.failure")}; return (
-

{t("player.turnstile.verifyingHumanity")}

+

{t("player.scraping.items.pending")}

) : null}
; HAS_ONBOARDING: boolean; ALLOW_AUTOPLAY: boolean; @@ -88,7 +86,6 @@ const env: Record = { NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, BACKEND_URL: import.meta.env.VITE_BACKEND_URL, DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, - TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, @@ -153,7 +150,6 @@ export function conf(): RuntimeConfig { NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true", HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true", - TURNSTILE_KEY: getKey("TURNSTILE_KEY"), DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") .split(",") .map((v) => v.trim()) diff --git a/src/stores/turnstile/index.tsx b/src/stores/turnstile/index.tsx index 67340f10..e6915479 100644 --- a/src/stores/turnstile/index.tsx +++ b/src/stores/turnstile/index.tsx @@ -1,11 +1,7 @@ -import { Turnstile } from "@marsidev/react-turnstile"; -import classNames from "classnames"; -import { useRef } from "react"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { reportCaptchaSolve } from "@/backend/helpers/report"; -import { conf } from "@/setup/config"; export interface TurnstileStore { isInWidget: boolean; @@ -84,47 +80,3 @@ export async function getTurnstileToken() { throw err; } } - -export function TurnstileProvider(props: { - isInPopout?: boolean; - onUpdateShow?: (show: boolean) => void; -}) { - const siteKey = conf().TURNSTILE_KEY; - const idRef = useRef(null); - const setTurnstile = useTurnstileStore((s) => s.setTurnstile); - const processToken = useTurnstileStore((s) => s.processToken); - if (!siteKey) return null; - return ( -
- { - idRef.current = widgetId; - setTurnstile(widgetId, "mwturnstile", !!props.isInPopout); - }} - onError={() => { - const id = idRef.current; - if (!id) return; - processToken(null, id); - }} - onSuccess={(token) => { - const id = idRef.current; - if (!id) return; - processToken(token, id); - props.onUpdateShow?.(false); - }} - onBeforeInteractive={() => { - props.onUpdateShow?.(true); - }} - /> -
- ); -} diff --git a/src/utils/proxyUrls.ts b/src/utils/proxyUrls.ts index 41573b79..03e82bf0 100644 --- a/src/utils/proxyUrls.ts +++ b/src/utils/proxyUrls.ts @@ -2,7 +2,7 @@ import { conf } from "@/setup/config"; import { useAuthStore } from "@/stores/auth"; const originalUrls = conf().PROXY_URLS; -const types = ["proxy", "api"] as const; +const types = ["proxy"] as const; type ParsedUrlType = (typeof types)[number]; @@ -73,9 +73,3 @@ export function getProxyUrls() { export function getM3U8ProxyUrls(): string[] { return conf().M3U8_PROXY_URLS; } - -export function getProviderApiUrls() { - return getParsedUrls() - .filter((v) => v.type === "api") - .map((v) => v.url); -} From 64bbc09e990cde43828077e37e1be877a52c8bb9 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:05:16 -0700 Subject: [PATCH 2/8] remove warning part and treat scraping error as playback error to auto resume --- src/pages/parts/player/ScrapingPart.tsx | 43 ++++++++++++++++++++----- src/pages/parts/util/WarningPart.tsx | 14 -------- 2 files changed, 35 insertions(+), 22 deletions(-) delete mode 100644 src/pages/parts/util/WarningPart.tsx diff --git a/src/pages/parts/player/ScrapingPart.tsx b/src/pages/parts/player/ScrapingPart.tsx index 20df4309..425e2312 100644 --- a/src/pages/parts/player/ScrapingPart.tsx +++ b/src/pages/parts/player/ScrapingPart.tsx @@ -21,8 +21,8 @@ import { useListCenter, useScrape, } from "@/hooks/useProviderScrape"; - -import { WarningPart } from "../util/WarningPart"; +import { playerStatus } from "@/stores/player/slices/source"; +import { usePlayerStore } from "@/stores/player/store"; export interface ScrapingProps { media: ScrapeMedia; @@ -40,10 +40,12 @@ export function ScrapingPart(props: ScrapingProps) { useScrape(); const isMounted = useMountedState(); const { t } = useTranslation(); + const setStatus = usePlayerStore((s) => s.setStatus); + const addFailedSource = usePlayerStore((s) => s.addFailedSource); + const sourceId = usePlayerStore((s) => s.sourceId); const containerRef = useRef(null); const listRef = useRef(null); - const [failedStartScrape, setFailedStartScrape] = useState(false); const renderedOnce = useListCenter( containerRef, listRef, @@ -86,8 +88,36 @@ export function ScrapingPart(props: ScrapingProps) { ), ); props.onGetStream?.(output); - })().catch(() => setFailedStartScrape(true)); - }, [startScraping, resumeScraping, props, report, isMounted]); + })().catch((error) => { + if (!isMounted()) return; + // Treat scraping failure as fatal error + // Mark current source as failed if we have one + if (sourceId) { + addFailedSource(sourceId); + } else if (currentSource) { + addFailedSource(currentSource); + } + // Set error and status to trigger PlaybackErrorPart + usePlayerStore.setState((s) => { + s.interface.error = { + errorName: "ScrapingError", + message: error?.message || "Failed to start scraping", + type: "global", + }; + s.status = playerStatus.PLAYBACK_ERROR; + }); + }); + }, [ + startScraping, + resumeScraping, + props, + report, + isMounted, + setStatus, + addFailedSource, + sourceId, + currentSource, + ]); let currentProviderIndex = sourceOrder.findIndex( (s) => s.id === currentSource || s.children.includes(currentSource ?? ""), @@ -95,9 +125,6 @@ export function ScrapingPart(props: ScrapingProps) { if (currentProviderIndex === -1) currentProviderIndex = sourceOrder.length - 1; - if (failedStartScrape) - return {t("player.scraping.items.failure")}; - return (
- - -
- {props.children} -
-
- ); -} 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 3/8] 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(",") From a019f3dab4ae9d1c28cc1a7adac355e1a67e48bb Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 21:45:05 -0700 Subject: [PATCH 4/8] add disconnect from account option --- src/assets/locales/en.json | 7 ++++- src/hooks/auth/useAuth.ts | 16 ++++++++++++ src/index.tsx | 52 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index b762fe3a..122c8975 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -992,9 +992,14 @@ "loadingUserError": { "logout": "Logout", "reset": "Reset custom server", + "disconnect": "Disconnect from backend", "text": "Failed to load your profile", "reload": "Reload", - "textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?" + "textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?", + "disconnectTitle": "Disconnect from backend?", + "disconnectMessage": "Disconnect from the account server, maintaining the most recent local data. Changes and watched content will not sync until you're signed in again.", + "disconnectConfirm": "Confirm", + "disconectCancel": "Cancel" }, "migration": { "failed": "Failed to migrate your data. 😿", diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 77c2fb86..32150c38 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -102,6 +102,21 @@ export function useAuth() { await userDataLogout(); }, [userDataLogout, backendUrl, currentAccount]); + const disconnectFromBackend = useCallback(async () => { + if (!currentAccount || !backendUrl) return; + try { + await removeSession( + backendUrl, + currentAccount.token, + currentAccount.sessionId, + ); + } catch { + // we dont care about failing to delete session + } + // Only remove the account, keep all local data + useAuthStore.getState().removeAccount(); + }, [backendUrl, currentAccount]); + const register = useCallback( async (registerData: RegistrationData) => { if (!backendUrl) return; @@ -215,6 +230,7 @@ export function useAuth() { profile, login, logout, + disconnectFromBackend, register, restore, importData, diff --git a/src/index.tsx b/src/index.tsx index e524488a..26bdf087 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,7 +4,7 @@ import "./stores/__old/imports"; import "@/setup/ga"; import "@/assets/css/index.css"; -import { StrictMode, Suspense, useCallback } from "react"; +import { StrictMode, Suspense, useCallback, useState } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { HelmetProvider } from "react-helmet-async"; @@ -62,15 +62,19 @@ function ErrorScreen(props: { showResetButton?: boolean; showLogoutButton?: boolean; showReloadButton?: boolean; + showDisconnectButton?: boolean; }) { const { t } = useTranslation(); - const { logout } = useAuth(); + const { logout, disconnectFromBackend } = useAuth(); const setBackendUrl = useAuthStore((s) => s.setBackendUrl); + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + const resetBackend = useCallback(() => { setBackendUrl(null); // eslint-disable-next-line no-restricted-globals location.reload(); }, [setBackendUrl]); + const logoutFromBackend = useCallback(() => { logout().then(() => { // eslint-disable-next-line no-restricted-globals @@ -78,6 +82,13 @@ function ErrorScreen(props: { }); }, [logout]); + const handleDisconnectConfirm = useCallback(() => { + disconnectFromBackend().then(() => { + // eslint-disable-next-line no-restricted-globals + location.reload(); + }); + }, [disconnectFromBackend]); + return (
) : null} + {props.showDisconnectButton ? ( +
+ +
+ ) : null} {props.showReloadButton ? (
) : null} + + {/* Disconnect Confirmation Modal */} + {showDisconnectConfirm && ( +
+
+

+ {t("screens.loadingUserError.disconnectTitle")} +

+

+ {t("screens.loadingUserError.disconnectMessage")} +

+
+ + +
+
+
+ )} ); } @@ -115,6 +161,7 @@ function AuthWrapper() { const backendUrl = conf().BACKEND_URL; const userBackendUrl = useBackendUrl(); const { t } = useTranslation(); + const isLoggedIn = !!useAuthStore((s) => s.account); const isCustomUrl = backendUrl !== userBackendUrl; @@ -124,6 +171,7 @@ function AuthWrapper() { {t( From 0d4c6471ab6f3c3c88a72007d51a800ea6a01222 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:12:33 -0700 Subject: [PATCH 5/8] fix metric reporting with multiple servers --- src/backend/helpers/report.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/backend/helpers/report.ts b/src/backend/helpers/report.ts index b4bdb5bf..10155835 100644 --- a/src/backend/helpers/report.ts +++ b/src/backend/helpers/report.ts @@ -5,14 +5,27 @@ import { useCallback } from "react"; import { isExtensionActiveCached } from "@/backend/extension/messaging"; import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; -import { BACKEND_URL } from "@/setup/constants"; +import { conf } from "@/setup/config"; import { useAuthStore } from "@/stores/auth"; import { PlayerMeta } from "@/stores/player/slices/source"; // for anybody who cares - these are anonymous metrics. // They are just used for figuring out if providers are broken or not -const metricsEndpoint = `${BACKEND_URL}/metrics/providers`; -const captchaMetricsEndpoint = `${BACKEND_URL}/metrics/captcha`; +// Metrics are always sent to the first configured backend +function getMetricsBackendUrl(): string | null { + const config = conf(); + return config.BACKEND_URLS.length > 0 + ? config.BACKEND_URLS[0] + : config.BACKEND_URL; +} + +function getMetricsEndpoint(path: string): string | null { + const backendUrl = getMetricsBackendUrl(); + return backendUrl ? `${backendUrl}${path}` : null; +} + +const metricsEndpoint = getMetricsEndpoint("/metrics/providers"); +const captchaMetricsEndpoint = getMetricsEndpoint("/metrics/captcha"); const batchId = () => nanoid(32); export type ProviderMetric = { @@ -45,7 +58,7 @@ function getStackTrace(error: Error, lines: number) { } export async function reportProviders(items: ProviderMetric[]): Promise { - if (!BACKEND_URL) return; + if (!metricsEndpoint) return; return ofetch(metricsEndpoint, { method: "POST", body: { @@ -158,7 +171,7 @@ export function useReportProviders() { } export function reportCaptchaSolve(success: boolean) { - if (!BACKEND_URL) return; + if (!captchaMetricsEndpoint) return; ofetch(captchaMetricsEndpoint, { method: "POST", body: { From 681172fe8e2881fbbf9975282b95daa36db147f2 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:16:52 -0700 Subject: [PATCH 6/8] persist entered custom backend url when navigating the site --- src/components/form/BackendSelector.tsx | 23 +++++++++++++++++++---- src/pages/Login.tsx | 9 +++++---- src/pages/Register.tsx | 16 +++++++++------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/components/form/BackendSelector.tsx b/src/components/form/BackendSelector.tsx index 8159d5f4..6d304997 100644 --- a/src/components/form/BackendSelector.tsx +++ b/src/components/form/BackendSelector.tsx @@ -100,9 +100,26 @@ export function BackendSelector({ showCustom = true, }: BackendSelectorProps) { const { t } = useTranslation(); - const [customUrl, setCustomUrl] = useState(""); + // Helper to strip protocol from URL for display + const stripProtocol = (url: string | null): string => { + if (!url) return ""; + return url.replace(/^https?:\/\//, ""); + }; + + // Initialize customUrl from selectedUrl if it's a custom URL (not in availableUrls) + const isCustomUrl = selectedUrl && !availableUrls.includes(selectedUrl); + const [customUrl, setCustomUrl] = useState( + isCustomUrl ? stripProtocol(selectedUrl) : "", + ); const [backendOptions, setBackendOptions] = useState([]); + // Update customUrl when selectedUrl changes and it's a custom URL + useEffect(() => { + if (selectedUrl && !availableUrls.includes(selectedUrl)) { + setCustomUrl(stripProtocol(selectedUrl)); + } + }, [selectedUrl, availableUrls]); + // Initialize and fetch meta for backend options useEffect(() => { const fetchMetas = async () => { @@ -142,9 +159,7 @@ export function BackendSelector({ }; const isCustomUrlSelected = - customUrl && - selectedUrl === customUrl && - !availableUrls.includes(selectedUrl); + selectedUrl !== null && !availableUrls.includes(selectedUrl); return (
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 179da392..6d648273 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -18,10 +18,6 @@ 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 = @@ -37,6 +33,11 @@ export function LoginPage() { currentBackendUrl ?? (availableBackends.length === 1 ? availableBackends[0] : null); + const [showBackendSelection, setShowBackendSelection] = useState(true); + const [selectedBackendUrl, setSelectedBackendUrl] = useState( + currentBackendUrl ?? null, + ); + const handleBackendSelect = (url: string | null) => { setSelectedBackendUrl(url); if (url) { diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index ec08556a..df485fb3 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -38,14 +38,8 @@ function CaptchaProvider(props: { export function RegisterPage() { const navigate = useNavigate(); 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 currentBackendUrl = useAuthStore((s) => s.backendUrl); const config = conf(); const availableBackends = config.BACKEND_URLS.length > 0 @@ -54,6 +48,14 @@ export function RegisterPage() { ? [config.BACKEND_URL] : []; + const [step, setStep] = useState(-1); + const [mnemonic, setMnemonic] = useState(null); + const [account, setAccount] = useState(null); + const [siteKey, setSiteKey] = useState(null); + const [selectedBackendUrl, setSelectedBackendUrl] = useState( + currentBackendUrl ?? null, + ); + const handleBackendSelect = (url: string | null) => { setSelectedBackendUrl(url); if (url) { From cf1527dc8ae392b5d96357f1896b36aa5a88038a Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:27:52 -0700 Subject: [PATCH 7/8] update error message --- src/assets/locales/en.json | 3 ++- src/components/form/BackendSelector.tsx | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 122c8975..10b98aee 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1143,7 +1143,8 @@ "description": "<0>Migrate my data to a new server.", "link": "Migrate my data" }, - "documentation": "Backend documentation" + "documentation": "Backend documentation", + "error": "Error connecting to backend" }, "setup": { "doSetup": "Do setup", diff --git a/src/components/form/BackendSelector.tsx b/src/components/form/BackendSelector.tsx index 6d304997..9587f968 100644 --- a/src/components/form/BackendSelector.tsx +++ b/src/components/form/BackendSelector.tsx @@ -67,9 +67,15 @@ function BackendOptionItem({
) : option.error ? ( -
-

{hostname}

-

{option.url}

+
+
+

{hostname}

+

{option.url}

+
+ + + {t("settings.connections.server.error")} +
) : option.meta ? (
From 41aaa4ec03b6925ad84650fd18a5bfc322ef97ce Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:28:40 -0700 Subject: [PATCH 8/8] Update LargeCard.tsx --- src/components/layout/LargeCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/LargeCard.tsx b/src/components/layout/LargeCard.tsx index dbc010af..fc5a1a8c 100644 --- a/src/components/layout/LargeCard.tsx +++ b/src/components/layout/LargeCard.tsx @@ -25,7 +25,7 @@ export function LargeCardText(props: { }) { return (
-
+
{props.icon ? (
{props.icon}
) : null}