pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'p-stream:production' into substranslate

authored by

vlOd and committed by
GitHub
44618524 49f95691

+743 -459
-2
Dockerfile
··· 22 22 ARG ONBOARDING_PROXY_INSTALL_LINK 23 23 ARG DISALLOWED_IDS 24 24 ARG CDN_REPLACEMENTS 25 - ARG TURNSTILE_KEY 26 25 ARG ALLOW_AUTOPLAY="false" 27 26 28 27 ENV VITE_PWA_ENABLED=${PWA_ENABLED} ··· 39 38 ENV VITE_ONBOARDING_PROXY_INSTALL_LINK=${ONBOARDING_PROXY_INSTALL_LINK} 40 39 ENV VITE_DISALLOWED_IDS=${DISALLOWED_IDS} 41 40 ENV VITE_CDN_REPLACEMENTS=${CDN_REPLACEMENTS} 42 - ENV VITE_TURNSTILE_KEY=${TURNSTILE_KEY} 43 41 ENV VITE_ALLOW_AUTOPLAY=${ALLOW_AUTOPLAY} 44 42 45 43 COPY . ./
-1
docker-compose.yaml
··· 19 19 # ONBOARDING_PROXY_INSTALL_LINK: "" 20 20 # DISALLOWED_IDS: "" 21 21 # CDN_REPLACEMENTS: "" 22 - # TURNSTILE_KEY: "" 23 22 ports: 24 23 - "80:80" 25 24 restart: unless-stopped
+3
example.env
··· 9 9 10 10 # make sure the domain does NOT have a slash at the end 11 11 VITE_APP_DOMAIN=http://localhost:5173 12 + 13 + # Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com") 14 + VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com
+1 -1
public/config.js
··· 12 12 // Whether to disable hash-based routing, leave this as false if you don't know what this is 13 13 VITE_NORMAL_ROUTER: true, 14 14 15 - // The backend URL to communicate with 15 + // The backend URL(s) to communicate with - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com") 16 16 VITE_BACKEND_URL: null, 17 17 18 18 // A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-<id>" and "movie-<id>"
+28 -10
src/assets/locales/en.json
··· 180 180 "title": "Account information" 181 181 } 182 182 }, 183 + "backendSelection": { 184 + "title": "Select Account Server", 185 + "description": "Choose which backend server to connect to", 186 + "customBackend": "Custom Backend", 187 + "customBackendPlaceholder": "https://", 188 + "confirm": "Confirm", 189 + "cancel": "Cancel", 190 + "active": "Active", 191 + "selecting": "Selecting..." 192 + }, 183 193 "trust": { 184 194 "failed": { 185 195 "text": "Did you configure it correctly?", ··· 950 960 "remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}", 951 961 "shortRegular": "{{timeWatched}}", 952 962 "shortRemaining": "-{{timeLeft}}" 953 - }, 954 - "turnstile": { 955 - "description": "Please prove your humanity by completing the quick challenge, this is to keep P-Stream safe.", 956 - "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).", 957 - "title": "Are You a Robot 🤖?", 958 - "verifyingHumanity": "Verifying your humanity... (^▽^)👍" 959 963 } 960 964 }, 961 965 "support": { ··· 991 995 "loadingUserError": { 992 996 "logout": "Logout", 993 997 "reset": "Reset custom server", 998 + "disconnect": "Disconnect from backend", 994 999 "text": "Failed to load your profile", 995 1000 "reload": "Reload", 996 - "textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?" 1001 + "textWithReset": "Failed to load your profile from your custom server, want to reset back to the default server?", 1002 + "disconnectTitle": "Disconnect from backend?", 1003 + "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.", 1004 + "disconnectConfirm": "Confirm", 1005 + "disconectCancel": "Cancel" 997 1006 }, 998 1007 "migration": { 999 1008 "failed": "Failed to migrate your data. 😿", ··· 1125 1134 "connections": { 1126 1135 "server": { 1127 1136 "description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>", 1128 - "label": "Custom server", 1137 + "label": "Backend Server", 1129 1138 "urlLabel": "Custom server URL", 1139 + "selectBackend": "Select Backend Server", 1140 + "currentBackend": "Current Backend", 1141 + "changeWarning": "Changing backend server will log you out. Continue?", 1142 + "confirm": "Log out and change server", 1143 + "cancel": "Cancel", 1144 + "changeWarningTitle": "Change Backend Server", 1130 1145 "migration": { 1131 1146 "description": "<0>Migrate my data</0> to a new server.", 1132 1147 "link": "Migrate my data" 1133 1148 }, 1134 - "documentation": "Backend documentation" 1149 + "documentation": "Backend documentation", 1150 + "error": "Error connecting to backend" 1135 1151 }, 1136 1152 "setup": { 1137 1153 "doSetup": "Do setup", ··· 1387 1403 "contentMismatch": "Cannot join watch party: The content does not match the host's content.", 1388 1404 "episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.", 1389 1405 "validating": "Validating watch party...", 1390 - "linkCopied": "Copied!" 1406 + "linkCopied": "Copied!", 1407 + "backendRequirement": "All users must use the same backend server", 1408 + "activeBackend": "Active Backend: {{backend}}" 1391 1409 } 1392 1410 }
+2 -142
src/backend/helpers/providerApi.ts
··· 1 - import { MetaOutput, NotFoundError, ScrapeMedia } from "@p-stream/providers"; 1 + import { MetaOutput } from "@p-stream/providers"; 2 2 import { jwtDecode } from "jwt-decode"; 3 - 4 - import { mwFetch } from "@/backend/helpers/fetch"; 5 - import { getTurnstileToken, isTurnstileInitialized } from "@/stores/turnstile"; 6 3 7 4 let metaDataCache: MetaOutput[] | null = null; 8 5 let token: null | string = null; ··· 31 28 return null; 32 29 } 33 30 34 - export async function fetchMetadata(base: string) { 35 - if (metaDataCache) return; 36 - const data = await mwFetch<MetaOutput[][]>(`${base}/metadata`); 37 - metaDataCache = data.flat(); 38 - } 39 - 40 - function scrapeMediaToQueryMedia(media: ScrapeMedia) { 41 - let extra: Record<string, string> = {}; 42 - if (media.type === "show") { 43 - extra = { 44 - episodeNumber: media.episode.number.toString(), 45 - episodeTmdbId: media.episode.tmdbId, 46 - seasonNumber: media.season.number.toString(), 47 - seasonTmdbId: media.season.tmdbId, 48 - }; 49 - } 50 - 51 - return { 52 - type: media.type, 53 - releaseYear: media.releaseYear.toString(), 54 - imdbId: media.imdbId, 55 - tmdbId: media.tmdbId, 56 - title: media.title, 57 - ...extra, 58 - }; 59 - } 60 - 61 - function addQueryDataToUrl(url: URL, data: Record<string, string | undefined>) { 62 - Object.entries(data).forEach((entry) => { 63 - if (entry[1]) url.searchParams.set(entry[0], entry[1]); 64 - }); 65 - } 66 - 67 - export function makeProviderUrl(base: string) { 68 - const makeUrl = (p: string) => new URL(`${base}${p}`); 69 - return { 70 - scrapeSource(sourceId: string, media: ScrapeMedia) { 71 - const url = makeUrl("/scrape/source"); 72 - addQueryDataToUrl(url, scrapeMediaToQueryMedia(media)); 73 - addQueryDataToUrl(url, { id: sourceId }); 74 - return url.toString(); 75 - }, 76 - scrapeAll( 77 - media: ScrapeMedia, 78 - sourceOrder?: string[], 79 - embedOrder?: string[], 80 - ) { 81 - const url = makeUrl("/scrape"); 82 - addQueryDataToUrl(url, scrapeMediaToQueryMedia(media)); 83 - if (sourceOrder && sourceOrder.length > 0) { 84 - url.searchParams.set("sourceOrder", sourceOrder.join(",")); 85 - } 86 - if (embedOrder && embedOrder.length > 0) { 87 - url.searchParams.set("embedOrder", embedOrder.join(",")); 88 - } 89 - return url.toString(); 90 - }, 91 - scrapeEmbed(embedId: string, embedUrl: string) { 92 - const url = makeUrl("/scrape/embed"); 93 - addQueryDataToUrl(url, { id: embedId, url: embedUrl }); 94 - return url.toString(); 95 - }, 96 - }; 97 - } 98 - 99 31 export async function getApiToken(): Promise<string | null> { 100 - let apiToken = getTokenIfValid(); 101 - if (!apiToken && isTurnstileInitialized()) { 102 - apiToken = `turnstile|${await getTurnstileToken()}`; 103 - } 104 - return apiToken; 105 - } 106 - 107 - function parseEventInput(inp: string): any { 108 - if (inp.length === 0) return {}; 109 - return JSON.parse(inp); 110 - } 111 - 112 - export async function connectServerSideEvents<T>( 113 - url: string, 114 - endEvents: string[], 115 - ) { 116 - const apiToken = await getApiToken(); 117 - 118 - // insert token, if its set 119 - const parsedUrl = new URL(url); 120 - if (apiToken) parsedUrl.searchParams.set("token", apiToken); 121 - const eventSource = new EventSource(parsedUrl.toString()); 122 - 123 - let promReject: (reason?: any) => void; 124 - let promResolve: (value: T) => void; 125 - const promise = new Promise<T>((resolve, reject) => { 126 - promResolve = resolve; 127 - promReject = reject; 128 - }); 129 - 130 - endEvents.forEach((evt) => { 131 - eventSource.addEventListener(evt, (e) => { 132 - eventSource.close(); 133 - promResolve(parseEventInput(e.data)); 134 - }); 135 - }); 136 - 137 - eventSource.addEventListener("token", (e) => { 138 - setApiToken(parseEventInput(e.data)); 139 - }); 140 - 141 - eventSource.addEventListener("error", (err: MessageEvent<any>) => { 142 - eventSource.close(); 143 - if (err.data) { 144 - const data = JSON.parse(err.data); 145 - let errObj = new Error("scrape error"); 146 - if (data.name === NotFoundError.name) 147 - errObj = new NotFoundError("Notfound from server"); 148 - Object.assign(errObj, data); 149 - promReject(errObj); 150 - return; 151 - } 152 - 153 - console.error("Failed to connect to SSE", err); 154 - promReject(err); 155 - }); 156 - 157 - eventSource.addEventListener("message", (ev) => { 158 - if (!ev) { 159 - eventSource.close(); 160 - return; 161 - } 162 - setTimeout(() => { 163 - promReject(new Error("SSE closed improperly")); 164 - }, 1000); 165 - }); 166 - 167 - return { 168 - promise: () => promise, 169 - on<Data>(event: string, cb: (data: Data) => void) { 170 - eventSource.addEventListener(event, (e) => cb(JSON.parse(e.data))); 171 - }, 172 - }; 32 + return getTokenIfValid(); 173 33 }
+18 -5
src/backend/helpers/report.ts
··· 5 5 6 6 import { isExtensionActiveCached } from "@/backend/extension/messaging"; 7 7 import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; 8 - import { BACKEND_URL } from "@/setup/constants"; 8 + import { conf } from "@/setup/config"; 9 9 import { useAuthStore } from "@/stores/auth"; 10 10 import { PlayerMeta } from "@/stores/player/slices/source"; 11 11 12 12 // for anybody who cares - these are anonymous metrics. 13 13 // They are just used for figuring out if providers are broken or not 14 - const metricsEndpoint = `${BACKEND_URL}/metrics/providers`; 15 - const captchaMetricsEndpoint = `${BACKEND_URL}/metrics/captcha`; 14 + // Metrics are always sent to the first configured backend 15 + function getMetricsBackendUrl(): string | null { 16 + const config = conf(); 17 + return config.BACKEND_URLS.length > 0 18 + ? config.BACKEND_URLS[0] 19 + : config.BACKEND_URL; 20 + } 21 + 22 + function getMetricsEndpoint(path: string): string | null { 23 + const backendUrl = getMetricsBackendUrl(); 24 + return backendUrl ? `${backendUrl}${path}` : null; 25 + } 26 + 27 + const metricsEndpoint = getMetricsEndpoint("/metrics/providers"); 28 + const captchaMetricsEndpoint = getMetricsEndpoint("/metrics/captcha"); 16 29 const batchId = () => nanoid(32); 17 30 18 31 export type ProviderMetric = { ··· 45 58 } 46 59 47 60 export async function reportProviders(items: ProviderMetric[]): Promise<void> { 48 - if (!BACKEND_URL) return; 61 + if (!metricsEndpoint) return; 49 62 return ofetch(metricsEndpoint, { 50 63 method: "POST", 51 64 body: { ··· 158 171 } 159 172 160 173 export function reportCaptchaSolve(success: boolean) { 161 - if (!BACKEND_URL) return; 174 + if (!captchaMetricsEndpoint) return; 162 175 ofetch(captchaMetricsEndpoint, { 163 176 method: "POST", 164 177 body: {
+1 -7
src/backend/providers/fetchers.ts
··· 6 6 7 7 import { sendExtensionRequest } from "@/backend/extension/messaging"; 8 8 import { getApiToken, setApiToken } from "@/backend/helpers/providerApi"; 9 - import { 10 - getM3U8ProxyUrls, 11 - getProviderApiUrls, 12 - getProxyUrls, 13 - } from "@/utils/proxyUrls"; 9 + import { getM3U8ProxyUrls, getProxyUrls } from "@/utils/proxyUrls"; 14 10 15 11 import { convertBodyToObject, getBodyTypeFromBody } from "../extension/request"; 16 12 ··· 28 24 } 29 25 30 26 export const getLoadbalancedProxyUrl = makeLoadbalancedList(getProxyUrls); 31 - export const getLoadbalancedProviderApiUrl = 32 - makeLoadbalancedList(getProviderApiUrls); 33 27 function getEnabledM3U8ProxyUrls() { 34 28 const allM3U8ProxyUrls = getM3U8ProxyUrls(); 35 29 const enabledProxies = localStorage.getItem("m3u8-proxy-enabled");
+244
src/components/form/BackendSelector.tsx
··· 1 + import classNames from "classnames"; 2 + import { useEffect, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + 5 + import { MetaResponse, getBackendMeta } from "@/backend/accounts/meta"; 6 + import { Button } from "@/components/buttons/Button"; 7 + import { Icon, Icons } from "@/components/Icon"; 8 + import { Loading } from "@/components/layout/Loading"; 9 + import { TextInputControl } from "@/components/text-inputs/TextInputControl"; 10 + 11 + interface BackendOption { 12 + url: string; 13 + meta: MetaResponse | null; 14 + loading: boolean; 15 + error: boolean; 16 + } 17 + 18 + interface BackendSelectorProps { 19 + selectedUrl: string | null; 20 + onSelect: (url: string | null) => void; 21 + availableUrls: string[]; 22 + showCustom?: boolean; 23 + } 24 + 25 + function BackendOptionItem({ 26 + option, 27 + isSelected, 28 + onClick, 29 + }: { 30 + option: BackendOption; 31 + isSelected: boolean; 32 + onClick: () => void; 33 + }) { 34 + const { t } = useTranslation(); 35 + const hostname = option.url ? new URL(option.url).hostname : undefined; 36 + 37 + return ( 38 + <button 39 + type="button" 40 + onClick={onClick} 41 + className={classNames( 42 + "w-full p-4 rounded-lg border-2 transition-colors text-left tabbable", 43 + isSelected 44 + ? "border-buttons-purple bg-buttons-purple/10" 45 + : "border-transparent bg-authentication-inputBg hover:bg-authentication-inputBg/80", 46 + )} 47 + > 48 + <div className="flex items-center gap-3"> 49 + <div 50 + className={classNames( 51 + "w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0", 52 + isSelected 53 + ? "border-buttons-purple bg-buttons-purple" 54 + : "border-type-secondary", 55 + )} 56 + > 57 + {isSelected ? ( 58 + <Icon icon={Icons.CHECKMARK} className="text-white text-xs" /> 59 + ) : null} 60 + </div> 61 + <div className="flex-1 min-w-0"> 62 + {option.loading ? ( 63 + <div className="flex items-center gap-2"> 64 + <Loading /> 65 + <span className="text-type-secondary text-sm"> 66 + {t("auth.backendSelection.selecting")} 67 + </span> 68 + </div> 69 + ) : option.error ? ( 70 + <div className="flex items-center gap-2"> 71 + <div className="flex flex-col"> 72 + <p className="text-white font-medium">{hostname}</p> 73 + <p className="text-type-secondary text-sm">{option.url}</p> 74 + </div> 75 + <Icon icon={Icons.WARNING} className="text-type-danger text-sm" /> 76 + <span className="text-type-danger text-sm"> 77 + {t("settings.connections.server.error")} 78 + </span> 79 + </div> 80 + ) : option.meta ? ( 81 + <div> 82 + <p className="text-white font-medium">{option.meta.name}</p> 83 + <p className="text-type-secondary text-sm">{hostname}</p> 84 + </div> 85 + ) : ( 86 + <div> 87 + <p className="text-white font-medium">{hostname}</p> 88 + <p className="text-type-secondary text-sm">{option.url}</p> 89 + </div> 90 + )} 91 + </div> 92 + {isSelected ? ( 93 + <span className="text-buttons-purple text-sm font-medium"> 94 + {t("auth.backendSelection.active")} 95 + </span> 96 + ) : null} 97 + </div> 98 + </button> 99 + ); 100 + } 101 + 102 + export function BackendSelector({ 103 + selectedUrl, 104 + onSelect, 105 + availableUrls, 106 + showCustom = true, 107 + }: BackendSelectorProps) { 108 + const { t } = useTranslation(); 109 + // Helper to strip protocol from URL for display 110 + const stripProtocol = (url: string | null): string => { 111 + if (!url) return ""; 112 + return url.replace(/^https?:\/\//, ""); 113 + }; 114 + 115 + // Initialize customUrl from selectedUrl if it's a custom URL (not in availableUrls) 116 + const isCustomUrl = selectedUrl && !availableUrls.includes(selectedUrl); 117 + const [customUrl, setCustomUrl] = useState( 118 + isCustomUrl ? stripProtocol(selectedUrl) : "", 119 + ); 120 + const [backendOptions, setBackendOptions] = useState<BackendOption[]>([]); 121 + 122 + // Update customUrl when selectedUrl changes and it's a custom URL 123 + useEffect(() => { 124 + if (selectedUrl && !availableUrls.includes(selectedUrl)) { 125 + setCustomUrl(stripProtocol(selectedUrl)); 126 + } 127 + }, [selectedUrl, availableUrls]); 128 + 129 + // Initialize and fetch meta for backend options 130 + useEffect(() => { 131 + const fetchMetas = async () => { 132 + const options: BackendOption[] = availableUrls.map((url) => ({ 133 + url, 134 + meta: null, 135 + loading: true, 136 + error: false, 137 + })); 138 + setBackendOptions(options); 139 + 140 + const promises = options.map(async (option) => { 141 + try { 142 + const meta = await getBackendMeta(option.url); 143 + return { ...option, meta, loading: false, error: false }; 144 + } catch { 145 + return { ...option, meta: null, loading: false, error: true }; 146 + } 147 + }); 148 + const results = await Promise.all(promises); 149 + setBackendOptions(results); 150 + }; 151 + 152 + if (availableUrls.length > 0) { 153 + fetchMetas(); 154 + } 155 + }, [availableUrls]); 156 + 157 + const handleCustomUrlSelect = () => { 158 + if (customUrl.trim()) { 159 + let url = customUrl.trim(); 160 + if (!url.startsWith("http://") && !url.startsWith("https://")) { 161 + url = `https://${url}`; 162 + } 163 + onSelect(url); 164 + } 165 + }; 166 + 167 + const isCustomUrlSelected = 168 + selectedUrl !== null && !availableUrls.includes(selectedUrl); 169 + 170 + return ( 171 + <div className="space-y-4"> 172 + {backendOptions.length > 0 ? ( 173 + <div className="space-y-3"> 174 + {backendOptions.map((option) => ( 175 + <BackendOptionItem 176 + key={option.url} 177 + option={option} 178 + isSelected={selectedUrl === option.url} 179 + onClick={() => onSelect(option.url)} 180 + /> 181 + ))} 182 + </div> 183 + ) : null} 184 + 185 + {showCustom && ( 186 + <div 187 + className={classNames( 188 + "w-full p-4 rounded-lg border-2 transition-colors", 189 + isCustomUrlSelected 190 + ? "border-buttons-purple bg-buttons-purple/10" 191 + : "border-transparent bg-authentication-inputBg", 192 + )} 193 + > 194 + <div className="space-y-3"> 195 + <div className="flex items-center gap-3"> 196 + <div 197 + className={classNames( 198 + "w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0", 199 + isCustomUrlSelected 200 + ? "border-buttons-purple bg-buttons-purple" 201 + : "border-type-secondary", 202 + )} 203 + > 204 + {isCustomUrlSelected ? ( 205 + <Icon icon={Icons.CHECKMARK} className="text-white text-xs" /> 206 + ) : null} 207 + </div> 208 + <div className="flex-1"> 209 + <p className="text-white font-medium"> 210 + {t("auth.backendSelection.customBackend")} 211 + </p> 212 + </div> 213 + {isCustomUrlSelected ? ( 214 + <span className="text-buttons-purple text-sm font-medium"> 215 + {t("auth.backendSelection.active")} 216 + </span> 217 + ) : null} 218 + </div> 219 + <div className="space-y-3"> 220 + <div className="grid grid-cols-[1fr,auto] items-center gap-2"> 221 + <TextInputControl 222 + value={customUrl} 223 + onChange={setCustomUrl} 224 + placeholder={ 225 + t("auth.backendSelection.customBackendPlaceholder") ?? 226 + undefined 227 + } 228 + className="w-full flex-1 bg-authentication-inputBg border-2 border-type-secondary/40 px-4 py-3 text-search-text focus:outline-none rounded-lg placeholder:text-gray-700" 229 + /> 230 + <Button 231 + theme="purple" 232 + onClick={handleCustomUrlSelect} 233 + disabled={!customUrl.trim()} 234 + > 235 + {t("auth.backendSelection.confirm")} 236 + </Button> 237 + </div> 238 + </div> 239 + </div> 240 + </div> 241 + )} 242 + </div> 243 + ); 244 + }
+1 -1
src/components/layout/LargeCard.tsx
··· 25 25 }) { 26 26 return ( 27 27 <div className="flex flex-col items-center text-center mb-8"> 28 - <div className="flex flex-col items-center text-center max-w-[318px]"> 28 + <div className="flex flex-col items-center text-center max-w-[320px]"> 29 29 {props.icon ? ( 30 30 <div className="text-2xl mb-4 text-largeCard-icon">{props.icon}</div> 31 31 ) : null}
+1 -42
src/components/overlays/OverlayDisplay.tsx
··· 2 2 import FocusTrap from "focus-trap-react"; 3 3 import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; 4 4 import { createPortal } from "react-dom"; 5 - import { useTranslation } from "react-i18next"; 6 5 7 6 import { Transition } from "@/components/utils/Transition"; 8 7 import { 9 8 useInternalOverlayRouter, 10 9 useRouterAnchorUpdate, 11 10 } from "@/hooks/useOverlayRouter"; 12 - import { TurnstileProvider, getTurnstile } from "@/stores/turnstile"; 13 11 14 12 export interface OverlayProps { 15 13 id: string; ··· 17 15 darken?: boolean; 18 16 } 19 17 20 - function TurnstileInteractive() { 21 - const { t } = useTranslation(); 22 - const [show, setShow] = useState(false); 23 - 24 - useEffect(() => { 25 - getTurnstile(); 26 - }, []); 27 - 28 - // this may not rerender with different dom structure, must be exactly the same always 29 - return ( 30 - <div 31 - className={classNames( 32 - "absolute w-full max-w-[43em] max-h-full p-5 md:p-10 rounded-lg bg-dropdown-altBackground select-none z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform overflow-auto", 33 - show ? "" : "hidden", 34 - )} 35 - > 36 - <div className="w-full h-full grid lg:grid-cols-[1fr,auto] gap-6 md:gap-7 items-center"> 37 - <div className="text-left"> 38 - <h2 className="text-type-emphasis font-bold text-lg md:text-xl mb-4 md:mb-6"> 39 - {t("player.turnstile.title")} 40 - </h2> 41 - <p className="text-type-emphasis"> 42 - {t("player.turnstile.description")} 43 - </p> 44 - </div> 45 - <TurnstileProvider 46 - isInPopout 47 - onUpdateShow={(shouldShow) => setShow(shouldShow)} 48 - /> 49 - </div> 50 - </div> 51 - ); 52 - } 53 - 54 18 export function OverlayDisplay(props: { children: ReactNode }) { 55 19 const router = useInternalOverlayRouter("hello world :)"); 56 20 const refRouter = useRef(router); ··· 63 27 r.close(); 64 28 }; 65 29 }, []); 66 - return ( 67 - <div className="popout-location"> 68 - <TurnstileInteractive /> 69 - {props.children} 70 - </div> 71 - ); 30 + return <div className="popout-location">{props.children}</div>; 72 31 } 73 32 74 33 export function OverlayPortal(props: {
+8
src/components/player/atoms/WatchPartyStatus.tsx
··· 3 3 4 4 import { Button } from "@/components/buttons/Button"; 5 5 import { Icon, Icons } from "@/components/Icon"; 6 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 6 7 import { useWatchPartySync } from "@/hooks/useWatchPartySync"; 7 8 import { useAuthStore } from "@/stores/auth"; 8 9 import { getProgressPercentage } from "@/stores/progress"; ··· 15 16 const [showNotification, setShowNotification] = useState(false); 16 17 const [lastUserCount, setLastUserCount] = useState(1); 17 18 const account = useAuthStore((s) => s.account); 19 + const backendUrl = useBackendUrl(); 20 + const backendHostname = backendUrl ? new URL(backendUrl).hostname : null; 18 21 19 22 const { 20 23 roomUsers, ··· 70 73 {roomCode} 71 74 </span> 72 75 </div> 76 + {backendHostname && ( 77 + <div className="w-full text-xs text-type-secondary text-center"> 78 + {t("watchParty.activeBackend", { backend: backendHostname })} 79 + </div> 80 + )} 73 81 74 82 <div className="w-full text-type-secondary flex justify-between items-center space-x-2"> 75 83 <div className="cursor-pointer" onClick={handleToggleExpanded}>
+11 -1
src/components/player/atoms/settings/WatchPartyView.tsx
··· 220 220 ) : ( 221 221 <> 222 222 <div className="flex flex-col gap-2"> 223 - <div className="text-center"> 223 + <div className="text-center space-y-2"> 224 + <div className="text-xs text-type-logo font-semibold flex flex-col gap-1 bg-type-danger/10 px-2 py-1 rounded mb-2"> 225 + <span className="text-xs"> 226 + {t("watchParty.backendRequirement")} 227 + </span> 228 + <span className="text-xs"> 229 + {t("watchParty.activeBackend", { 230 + backend: backendUrl || "Unknown", 231 + })} 232 + </span> 233 + </div> 224 234 <Trans 225 235 i18nKey={ 226 236 isHost ? "watchParty.isHost" : "watchParty.isGuest"
+12 -49
src/components/player/hooks/useSourceSelection.ts
··· 8 8 import { isExtensionActiveCached } from "@/backend/extension/messaging"; 9 9 import { prepareStream } from "@/backend/extension/streams"; 10 10 import { 11 - connectServerSideEvents, 12 - makeProviderUrl, 13 - } from "@/backend/helpers/providerApi"; 14 - import { 15 11 scrapeSourceOutputToProviderMetric, 16 12 useReportProviders, 17 13 } from "@/backend/helpers/report"; 18 - import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; 19 14 import { getProviders } from "@/backend/providers/providers"; 20 15 import { convertProviderCaption } from "@/components/player/utils/captions"; 21 16 import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource"; ··· 60 55 ); 61 56 62 57 const [request, run] = useAsyncFn(async () => { 63 - const providerApiUrl = getLoadbalancedProviderApiUrl(); 64 58 let result: EmbedOutput | undefined; 65 59 if (!meta) return; 66 60 try { 67 - if (providerApiUrl && !isExtensionActiveCached()) { 68 - const baseUrlMaker = makeProviderUrl(providerApiUrl); 69 - const conn = await connectServerSideEvents<EmbedOutput>( 70 - baseUrlMaker.scrapeEmbed(embedId, url), 71 - ["completed", "noOutput"], 72 - ); 73 - result = await conn.promise(); 74 - } else { 75 - result = await getProviders().runEmbedScraper({ 76 - id: embedId, 77 - url, 78 - }); 79 - } 61 + result = await getProviders().runEmbedScraper({ 62 + id: embedId, 63 + url, 64 + }); 80 65 } catch (err) { 81 66 console.error(`Failed to scrape ${embedId}`, err); 82 67 const notFound = err instanceof NotFoundError; ··· 148 133 if (!sourceId || !meta) return null; 149 134 setEmbedId(null); 150 135 const scrapeMedia = metaToScrapeMedia(meta); 151 - const providerApiUrl = getLoadbalancedProviderApiUrl(); 152 136 153 137 let result: SourcererOutput | undefined; 154 138 try { 155 - if (providerApiUrl && !isExtensionActiveCached()) { 156 - const baseUrlMaker = makeProviderUrl(providerApiUrl); 157 - const conn = await connectServerSideEvents<SourcererOutput>( 158 - baseUrlMaker.scrapeSource(sourceId, scrapeMedia), 159 - ["completed", "noOutput"], 160 - ); 161 - result = await conn.promise(); 162 - } else { 163 - result = await getProviders().runSourceScraper({ 164 - id: sourceId, 165 - media: scrapeMedia, 166 - }); 167 - } 139 + result = await getProviders().runSourceScraper({ 140 + id: sourceId, 141 + media: scrapeMedia, 142 + }); 168 143 } catch (err) { 169 144 console.error(`Failed to scrape ${sourceId}`, err); 170 145 const notFound = err instanceof NotFoundError; ··· 199 174 let embedResult: EmbedOutput | undefined; 200 175 if (!meta) return; 201 176 try { 202 - if (providerApiUrl && !isExtensionActiveCached()) { 203 - const baseUrlMaker = makeProviderUrl(providerApiUrl); 204 - const conn = await connectServerSideEvents<EmbedOutput>( 205 - baseUrlMaker.scrapeEmbed( 206 - result.embeds[0].embedId, 207 - result.embeds[0].url, 208 - ), 209 - ["completed", "noOutput"], 210 - ); 211 - embedResult = await conn.promise(); 212 - } else { 213 - embedResult = await getProviders().runEmbedScraper({ 214 - id: result.embeds[0].embedId, 215 - url: result.embeds[0].url, 216 - }); 217 - } 177 + embedResult = await getProviders().runEmbedScraper({ 178 + id: result.embeds[0].embedId, 179 + url: result.embeds[0].url, 180 + }); 218 181 } catch (err) { 219 182 console.error(`Failed to scrape ${result.embeds[0].embedId}`, err); 220 183 const notFound = err instanceof NotFoundError;
+16
src/hooks/auth/useAuth.ts
··· 102 102 await userDataLogout(); 103 103 }, [userDataLogout, backendUrl, currentAccount]); 104 104 105 + const disconnectFromBackend = useCallback(async () => { 106 + if (!currentAccount || !backendUrl) return; 107 + try { 108 + await removeSession( 109 + backendUrl, 110 + currentAccount.token, 111 + currentAccount.sessionId, 112 + ); 113 + } catch { 114 + // we dont care about failing to delete session 115 + } 116 + // Only remove the account, keep all local data 117 + useAuthStore.getState().removeAccount(); 118 + }, [backendUrl, currentAccount]); 119 + 105 120 const register = useCallback( 106 121 async (registerData: RegistrationData) => { 107 122 if (!backendUrl) return; ··· 215 230 profile, 216 231 login, 217 232 logout, 233 + disconnectFromBackend, 218 234 register, 219 235 restore, 220 236 importData,
+6 -1
src/hooks/auth/useBackendUrl.ts
··· 3 3 4 4 export function useBackendUrl(): string | null { 5 5 const backendUrl = useAuthStore((s) => s.backendUrl); 6 - return backendUrl ?? conf().BACKEND_URL; 6 + const config = conf(); 7 + return ( 8 + backendUrl ?? 9 + config.BACKEND_URL ?? 10 + (config.BACKEND_URLS.length > 0 ? config.BACKEND_URLS[0] : null) 11 + ); 7 12 }
+1 -29
src/hooks/useProviderScrape.tsx
··· 3 3 4 4 import { isExtensionActiveCached } from "@/backend/extension/messaging"; 5 5 import { prepareStream } from "@/backend/extension/streams"; 6 - import { 7 - connectServerSideEvents, 8 - getCachedMetadata, 9 - makeProviderUrl, 10 - } from "@/backend/helpers/providerApi"; 11 - import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; 6 + import { getCachedMetadata } from "@/backend/helpers/providerApi"; 12 7 import { getProviders } from "@/backend/providers/providers"; 13 8 import { getMediaKey } from "@/stores/player/slices/source"; 14 9 import { usePlayerStore } from "@/stores/player/store"; ··· 244 239 (id) => !allFailedEmbedIds.includes(id), 245 240 ) 246 241 : undefined; 247 - 248 - const providerApiUrl = getLoadbalancedProviderApiUrl(); 249 - if (providerApiUrl && !isExtensionActiveCached()) { 250 - startScrape(); 251 - const baseUrlMaker = makeProviderUrl(providerApiUrl); 252 - const conn = await connectServerSideEvents<RunOutput | "">( 253 - baseUrlMaker.scrapeAll( 254 - media, 255 - filteredSourceOrder, 256 - filteredEmbedOrder, 257 - ), 258 - ["completed", "noOutput"], 259 - ); 260 - conn.on("init", initEvent); 261 - conn.on("start", startEvent); 262 - conn.on("update", updateEvent); 263 - conn.on("discoverEmbeds", discoverEmbedsEvent); 264 - const sseOutput = await conn.promise(); 265 - if (sseOutput && isExtensionActiveCached()) 266 - await prepareStream(sseOutput.stream); 267 - 268 - return getResult(sseOutput === "" ? null : sseOutput); 269 - } 270 242 271 243 startScrape(); 272 244 const providers = getProviders();
+50 -2
src/index.tsx
··· 4 4 import "@/setup/ga"; 5 5 import "@/assets/css/index.css"; 6 6 7 - import { StrictMode, Suspense, useCallback } from "react"; 7 + import { StrictMode, Suspense, useCallback, useState } from "react"; 8 8 import type { ReactNode } from "react"; 9 9 import { createRoot } from "react-dom/client"; 10 10 import { HelmetProvider } from "react-helmet-async"; ··· 62 62 showResetButton?: boolean; 63 63 showLogoutButton?: boolean; 64 64 showReloadButton?: boolean; 65 + showDisconnectButton?: boolean; 65 66 }) { 66 67 const { t } = useTranslation(); 67 - const { logout } = useAuth(); 68 + const { logout, disconnectFromBackend } = useAuth(); 68 69 const setBackendUrl = useAuthStore((s) => s.setBackendUrl); 70 + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); 71 + 69 72 const resetBackend = useCallback(() => { 70 73 setBackendUrl(null); 71 74 // eslint-disable-next-line no-restricted-globals 72 75 location.reload(); 73 76 }, [setBackendUrl]); 77 + 74 78 const logoutFromBackend = useCallback(() => { 75 79 logout().then(() => { 76 80 // eslint-disable-next-line no-restricted-globals ··· 78 82 }); 79 83 }, [logout]); 80 84 85 + const handleDisconnectConfirm = useCallback(() => { 86 + disconnectFromBackend().then(() => { 87 + // eslint-disable-next-line no-restricted-globals 88 + location.reload(); 89 + }); 90 + }, [disconnectFromBackend]); 91 + 81 92 return ( 82 93 <LargeTextPart 83 94 iconSlot={ ··· 99 110 </Button> 100 111 </div> 101 112 ) : null} 113 + {props.showDisconnectButton ? ( 114 + <div className="mt-6"> 115 + <Button 116 + theme="secondary" 117 + onClick={() => setShowDisconnectConfirm(true)} 118 + > 119 + {t("screens.loadingUserError.disconnect")} 120 + </Button> 121 + </div> 122 + ) : null} 102 123 {props.showReloadButton ? ( 103 124 <div className="mt-6"> 104 125 <Button theme="secondary" onClick={() => window.location.reload()}> ··· 106 127 </Button> 107 128 </div> 108 129 ) : null} 130 + 131 + {/* Disconnect Confirmation Modal */} 132 + {showDisconnectConfirm && ( 133 + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> 134 + <div className="bg-modal-background rounded-xl p-8 max-w-md mx-4"> 135 + <h2 className="text-white text-xl font-semibold mb-4"> 136 + {t("screens.loadingUserError.disconnectTitle")} 137 + </h2> 138 + <p className="text-type-secondary mb-6"> 139 + {t("screens.loadingUserError.disconnectMessage")} 140 + </p> 141 + <div className="flex gap-3 justify-end"> 142 + <Button 143 + theme="secondary" 144 + onClick={() => setShowDisconnectConfirm(false)} 145 + > 146 + {t("screens.loadingUserError.disconectCancel")} 147 + </Button> 148 + <Button theme="danger" onClick={handleDisconnectConfirm}> 149 + {t("screens.loadingUserError.disconnectConfirm")} 150 + </Button> 151 + </div> 152 + </div> 153 + </div> 154 + )} 109 155 </LargeTextPart> 110 156 ); 111 157 } ··· 115 161 const backendUrl = conf().BACKEND_URL; 116 162 const userBackendUrl = useBackendUrl(); 117 163 const { t } = useTranslation(); 164 + const isLoggedIn = !!useAuthStore((s) => s.account); 118 165 119 166 const isCustomUrl = backendUrl !== userBackendUrl; 120 167 ··· 124 171 <ErrorScreen 125 172 showResetButton={isCustomUrl} 126 173 showLogoutButton={!isCustomUrl} 174 + showDisconnectButton={!isCustomUrl} 127 175 showReloadButton={!isCustomUrl} 128 176 > 129 177 {t(
+78 -5
src/pages/Login.tsx
··· 1 + import { useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 1 3 import { useNavigate } from "react-router-dom"; 2 4 5 + import { Button } from "@/components/buttons/Button"; 6 + import { BackendSelector } from "@/components/form/BackendSelector"; 7 + import { 8 + LargeCard, 9 + LargeCardButtons, 10 + LargeCardText, 11 + } from "@/components/layout/LargeCard"; 3 12 import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 4 13 import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart"; 5 14 import { PageTitle } from "@/pages/parts/util/PageTitle"; 15 + import { conf } from "@/setup/config"; 16 + import { useAuthStore } from "@/stores/auth"; 6 17 7 18 export function LoginPage() { 8 19 const navigate = useNavigate(); 20 + const { t } = useTranslation(); 21 + const setBackendUrl = useAuthStore((s) => s.setBackendUrl); 22 + const config = conf(); 23 + const availableBackends = 24 + config.BACKEND_URLS.length > 0 25 + ? config.BACKEND_URLS 26 + : config.BACKEND_URL 27 + ? [config.BACKEND_URL] 28 + : []; 29 + 30 + // If there's only one backend and user hasn't selected a custom one, auto-select it 31 + const currentBackendUrl = useAuthStore((s) => s.backendUrl); 32 + const defaultBackend = 33 + currentBackendUrl ?? 34 + (availableBackends.length === 1 ? availableBackends[0] : null); 35 + 36 + const [showBackendSelection, setShowBackendSelection] = useState(true); 37 + const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>( 38 + currentBackendUrl ?? null, 39 + ); 40 + 41 + const handleBackendSelect = (url: string | null) => { 42 + setSelectedBackendUrl(url); 43 + if (url) { 44 + setBackendUrl(url); 45 + } 46 + }; 47 + 48 + const handleContinue = () => { 49 + if (selectedBackendUrl || defaultBackend) { 50 + if (selectedBackendUrl) { 51 + setBackendUrl(selectedBackendUrl); 52 + } else if (defaultBackend) { 53 + setBackendUrl(defaultBackend); 54 + } 55 + setShowBackendSelection(false); 56 + } 57 + }; 9 58 10 59 return ( 11 60 <SubPageLayout> 12 61 <PageTitle subpage k="global.pages.login" /> 13 - <LoginFormPart 14 - onLogin={() => { 15 - navigate("/"); 16 - }} 17 - /> 62 + {showBackendSelection && 63 + (availableBackends.length > 1 || !defaultBackend) ? ( 64 + <LargeCard> 65 + <LargeCardText title={t("auth.backendSelection.title")}> 66 + {t("auth.backendSelection.description")} 67 + </LargeCardText> 68 + <BackendSelector 69 + selectedUrl={selectedBackendUrl ?? defaultBackend} 70 + onSelect={handleBackendSelect} 71 + availableUrls={availableBackends} 72 + showCustom 73 + /> 74 + <LargeCardButtons> 75 + <Button 76 + theme="purple" 77 + onClick={handleContinue} 78 + disabled={!selectedBackendUrl && !defaultBackend} 79 + > 80 + {t("auth.register.information.next")} 81 + </Button> 82 + </LargeCardButtons> 83 + </LargeCard> 84 + ) : ( 85 + <LoginFormPart 86 + onLogin={() => { 87 + navigate("/"); 88 + }} 89 + /> 90 + )} 18 91 </SubPageLayout> 19 92 ); 20 93 }
+59 -1
src/pages/Register.tsx
··· 1 1 import { useState } from "react"; 2 2 import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { useNavigate } from "react-router-dom"; 4 5 5 6 import { MetaResponse } from "@/backend/accounts/meta"; 7 + import { Button } from "@/components/buttons/Button"; 8 + import { BackendSelector } from "@/components/form/BackendSelector"; 9 + import { 10 + LargeCard, 11 + LargeCardButtons, 12 + LargeCardText, 13 + } from "@/components/layout/LargeCard"; 6 14 import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 7 15 import { 8 16 AccountCreatePart, ··· 12 20 import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart"; 13 21 import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart"; 14 22 import { PageTitle } from "@/pages/parts/util/PageTitle"; 23 + import { conf } from "@/setup/config"; 24 + import { useAuthStore } from "@/stores/auth"; 15 25 16 26 function CaptchaProvider(props: { 17 27 siteKey: string | null; ··· 27 37 28 38 export function RegisterPage() { 29 39 const navigate = useNavigate(); 30 - const [step, setStep] = useState(0); 40 + const { t } = useTranslation(); 41 + const setBackendUrl = useAuthStore((s) => s.setBackendUrl); 42 + const currentBackendUrl = useAuthStore((s) => s.backendUrl); 43 + const config = conf(); 44 + const availableBackends = 45 + config.BACKEND_URLS.length > 0 46 + ? config.BACKEND_URLS 47 + : config.BACKEND_URL 48 + ? [config.BACKEND_URL] 49 + : []; 50 + 51 + const [step, setStep] = useState(-1); 31 52 const [mnemonic, setMnemonic] = useState<null | string>(null); 32 53 const [account, setAccount] = useState<null | AccountProfile>(null); 33 54 const [siteKey, setSiteKey] = useState<string | null>(null); 55 + const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>( 56 + currentBackendUrl ?? null, 57 + ); 58 + 59 + const handleBackendSelect = (url: string | null) => { 60 + setSelectedBackendUrl(url); 61 + if (url) { 62 + setBackendUrl(url); 63 + } 64 + }; 34 65 35 66 return ( 36 67 <CaptchaProvider siteKey={siteKey}> 37 68 <SubPageLayout> 38 69 <PageTitle subpage k="global.pages.register" /> 70 + {step === -1 ? ( 71 + <LargeCard> 72 + <LargeCardText title={t("auth.backendSelection.title")}> 73 + {t("auth.backendSelection.description")} 74 + </LargeCardText> 75 + <BackendSelector 76 + selectedUrl={selectedBackendUrl} 77 + onSelect={handleBackendSelect} 78 + availableUrls={availableBackends} 79 + showCustom 80 + /> 81 + <LargeCardButtons> 82 + <Button 83 + theme="purple" 84 + onClick={() => { 85 + if (selectedBackendUrl) { 86 + setStep(0); 87 + } 88 + }} 89 + disabled={!selectedBackendUrl} 90 + > 91 + {t("auth.register.information.next")} 92 + </Button> 93 + </LargeCardButtons> 94 + </LargeCard> 95 + ) : null} 39 96 {step === 0 ? ( 40 97 <TrustBackendPart 98 + backendUrl={selectedBackendUrl} 41 99 onNext={(meta: MetaResponse) => { 42 100 setSiteKey( 43 101 meta.hasCaptcha && meta.captchaClientKey
+55 -8
src/pages/Settings.tsx
··· 16 16 import { SearchBarInput } from "@/components/form/SearchBar"; 17 17 import { ThinContainer } from "@/components/layout/ThinContainer"; 18 18 import { WideContainer } from "@/components/layout/WideContainer"; 19 + import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; 19 20 import { UserIcons } from "@/components/UserIcon"; 20 21 import { Divider } from "@/components/utils/Divider"; 21 - import { Heading1 } from "@/components/utils/Text"; 22 + import { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; 22 23 import { Transition } from "@/components/utils/Transition"; 23 24 import { useAuth } from "@/hooks/auth/useAuth"; 24 25 import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; ··· 168 169 const [searchQuery, setSearchQuery] = useState(""); 169 170 const [selectedCategory, setSelectedCategory] = useState<string | null>(null); 170 171 const prevCategoryRef = useRef<string | null>(null); 172 + const backendChangeModal = useModal("settings-backend-change-confirmation"); 173 + const [pendingBackendChange, setPendingBackendChange] = useState< 174 + string | null 175 + >(null); 171 176 172 177 useEffect(() => { 173 178 const hash = window.location.hash; ··· 730 735 updateProfile(state.profile.state); 731 736 } 732 737 733 - // when backend url gets changed, log the user out first 738 + // when backend url gets changed, show confirmation and log the user out (only if logged in) 734 739 if (state.backendUrl.changed) { 735 - await logout(); 736 - 737 740 let url = state.backendUrl.state; 738 741 if (url && !url.startsWith("http://") && !url.startsWith("https://")) { 739 742 url = `https://${url}`; 740 743 } 741 - 744 + if (account) { 745 + // User is logged in - show confirmation 746 + setPendingBackendChange(url); 747 + backendChangeModal.show(); 748 + return; 749 + } 750 + // User is not logged in - just update without confirmation 742 751 setBackendUrl(url); 743 752 } 744 753 }, [ 745 754 account, 746 755 backendUrl, 756 + backendChangeModal, 757 + setPendingBackendChange, 758 + state, 759 + setBackendUrl, 747 760 setEnableThumbnails, 748 761 setFebboxKey, 749 762 setdebridToken, 750 763 setdebridService, 751 - state, 752 764 setEnableAutoplay, 753 765 setEnableSkipCredits, 754 766 setEnableDiscover, ··· 766 778 updateDeviceName, 767 779 updateProfile, 768 780 updateNickname, 769 - logout, 770 - setBackendUrl, 771 781 setProxyTmdb, 772 782 setEnableCarouselView, 773 783 setEnableMinimalCards, ··· 948 958 </Button> 949 959 </div> 950 960 </Transition> 961 + {account && ( 962 + <Modal id={backendChangeModal.id}> 963 + <ModalCard> 964 + <Heading2 className="!mt-0 !mb-4"> 965 + {t("settings.connections.server.changeWarningTitle")} 966 + </Heading2> 967 + <Paragraph className="!mt-1 !mb-6"> 968 + {t("settings.connections.server.changeWarning")} 969 + </Paragraph> 970 + <div className="flex justify-end gap-3"> 971 + <Button 972 + theme="secondary" 973 + onClick={() => { 974 + backendChangeModal.hide(); 975 + setPendingBackendChange(null); 976 + state.backendUrl.set(backendUrlSetting); 977 + }} 978 + > 979 + {t("actions.cancel")} 980 + </Button> 981 + <Button 982 + theme="purple" 983 + onClick={async () => { 984 + backendChangeModal.hide(); 985 + if (pendingBackendChange !== null) { 986 + await logout(); 987 + setBackendUrl(pendingBackendChange); 988 + setPendingBackendChange(null); 989 + } 990 + }} 991 + > 992 + {t("actions.confirm")} 993 + </Button> 994 + </div> 995 + </ModalCard> 996 + </Modal> 997 + )} 951 998 </SubPageLayout> 952 999 ); 953 1000 }
+3 -1
src/pages/parts/auth/TrustBackendPart.tsx
··· 16 16 import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 17 17 18 18 interface TrustBackendPartProps { 19 + backendUrl?: string | null; 19 20 onNext?: (meta: MetaResponse) => void; 20 21 } 21 22 22 23 export function TrustBackendPart(props: TrustBackendPartProps) { 23 24 const navigate = useNavigate(); 24 - const backendUrl = useBackendUrl(); 25 + const defaultBackendUrl = useBackendUrl(); 26 + const backendUrl = props.backendUrl ?? defaultBackendUrl; 25 27 const hostname = useMemo( 26 28 () => (backendUrl ? new URL(backendUrl).hostname : undefined), 27 29 [backendUrl],
+6 -41
src/pages/parts/player/MetaPart.tsx
··· 5 5 6 6 import { isAllowedExtensionVersion } from "@/backend/extension/compatibility"; 7 7 import { extensionInfo, sendPage } from "@/backend/extension/messaging"; 8 - import { 9 - fetchMetadata, 10 - setCachedMetadata, 11 - } from "@/backend/helpers/providerApi"; 8 + import { setCachedMetadata } from "@/backend/helpers/providerApi"; 12 9 import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; 13 10 import { decodeTMDBId } from "@/backend/metadata/tmdb"; 14 11 import { MWMediaType } from "@/backend/metadata/types/mw"; 15 - import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; 16 12 import { getProviders } from "@/backend/providers/providers"; 17 13 import { Button } from "@/components/buttons/Button"; 18 14 import { Icons } from "@/components/Icon"; ··· 52 48 if (!info.hasPermission) throw new Error("extension-no-permission"); 53 49 } 54 50 55 - // use api metadata or providers metadata 56 - const providerApiUrl = getLoadbalancedProviderApiUrl(); 57 - if (providerApiUrl && !isValidExtension) { 58 - try { 59 - await fetchMetadata(providerApiUrl); 60 - } catch (err) { 61 - throw new Error("failed-api-metadata"); 62 - } 63 - } else { 64 - setCachedMetadata([ 65 - ...getProviders().listSources(), 66 - ...getProviders().listEmbeds(), 67 - ]); 68 - } 51 + // use providers metadata 52 + setCachedMetadata([ 53 + ...getProviders().listSources(), 54 + ...getProviders().listEmbeds(), 55 + ]); 69 56 70 57 // get media meta data 71 58 let data: ReturnType<typeof decodeTMDBId> = null; ··· 147 134 </IconPill> 148 135 <Title>{t("player.metadata.legal.title")}</Title> 149 136 <Paragraph>{t("player.metadata.legal.text")}</Paragraph> 150 - <Button 151 - href="/" 152 - theme="purple" 153 - padding="md:px-12 p-2.5" 154 - className="mt-6" 155 - > 156 - {t("player.metadata.failed.homeButton")} 157 - </Button> 158 - </ErrorContainer> 159 - </ErrorLayout> 160 - ); 161 - } 162 - 163 - if (error && error.message === "failed-api-metadata") { 164 - return ( 165 - <ErrorLayout> 166 - <ErrorContainer> 167 - <IconPill icon={Icons.WAND}> 168 - {t("player.metadata.failed.badge")} 169 - </IconPill> 170 - <Title>{t("player.metadata.api.text")}</Title> 171 - <Paragraph>{t("player.metadata.api.title")}</Paragraph> 172 137 <Button 173 138 href="/" 174 139 theme="purple"
+1 -4
src/pages/parts/player/ScrapeErrorPart.tsx
··· 19 19 import { usePreferencesStore } from "@/stores/preferences"; 20 20 import { getExtensionState } from "@/utils/extension"; 21 21 import type { ExtensionStatus } from "@/utils/extension"; 22 - import { getProviderApiUrls } from "@/utils/proxyUrls"; 23 22 24 23 import { ErrorCardInModal } from "../errors/ErrorCard"; 25 24 ··· 42 41 const error = useMemo(() => { 43 42 const data = props.data; 44 43 let str = ""; 45 - const apiUrls = getProviderApiUrls(); 46 - str += `URL - ${location.pathname}\n`; 47 - str += `API - ${apiUrls.length > 0}\n\n`; 44 + str += `URL - ${location.pathname}\n\n`; 48 45 Object.values(data.sources).forEach((v) => { 49 46 str += `${v.id}: ${v.status}\n`; 50 47 if (v.reason) str += `${v.reason}\n`;
+36 -9
src/pages/parts/player/ScrapingPart.tsx
··· 21 21 useListCenter, 22 22 useScrape, 23 23 } from "@/hooks/useProviderScrape"; 24 - 25 - import { WarningPart } from "../util/WarningPart"; 24 + import { playerStatus } from "@/stores/player/slices/source"; 25 + import { usePlayerStore } from "@/stores/player/store"; 26 26 27 27 export interface ScrapingProps { 28 28 media: ScrapeMedia; ··· 40 40 useScrape(); 41 41 const isMounted = useMountedState(); 42 42 const { t } = useTranslation(); 43 + const setStatus = usePlayerStore((s) => s.setStatus); 44 + const addFailedSource = usePlayerStore((s) => s.addFailedSource); 45 + const sourceId = usePlayerStore((s) => s.sourceId); 43 46 44 47 const containerRef = useRef<HTMLDivElement | null>(null); 45 48 const listRef = useRef<HTMLDivElement | null>(null); 46 - const [failedStartScrape, setFailedStartScrape] = useState<boolean>(false); 47 49 const renderedOnce = useListCenter( 48 50 containerRef, 49 51 listRef, ··· 86 88 ), 87 89 ); 88 90 props.onGetStream?.(output); 89 - })().catch(() => setFailedStartScrape(true)); 90 - }, [startScraping, resumeScraping, props, report, isMounted]); 91 + })().catch((error) => { 92 + if (!isMounted()) return; 93 + // Treat scraping failure as fatal error 94 + // Mark current source as failed if we have one 95 + if (sourceId) { 96 + addFailedSource(sourceId); 97 + } else if (currentSource) { 98 + addFailedSource(currentSource); 99 + } 100 + // Set error and status to trigger PlaybackErrorPart 101 + usePlayerStore.setState((s) => { 102 + s.interface.error = { 103 + errorName: "ScrapingError", 104 + message: error?.message || "Failed to start scraping", 105 + type: "global", 106 + }; 107 + s.status = playerStatus.PLAYBACK_ERROR; 108 + }); 109 + }); 110 + }, [ 111 + startScraping, 112 + resumeScraping, 113 + props, 114 + report, 115 + isMounted, 116 + setStatus, 117 + addFailedSource, 118 + sourceId, 119 + currentSource, 120 + ]); 91 121 92 122 let currentProviderIndex = sourceOrder.findIndex( 93 123 (s) => s.id === currentSource || s.children.includes(currentSource ?? ""), ··· 95 125 if (currentProviderIndex === -1) 96 126 currentProviderIndex = sourceOrder.length - 1; 97 127 98 - if (failedStartScrape) 99 - return <WarningPart>{t("player.turnstile.error")}</WarningPart>; 100 - 101 128 return ( 102 129 <div 103 130 className="h-full w-full relative dir-neutral:origin-top-left flex" ··· 106 133 {!sourceOrder || sourceOrder.length === 0 ? ( 107 134 <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center flex flex-col justify-center z-0"> 108 135 <Loading className="mb-8" /> 109 - <p>{t("player.turnstile.verifyingHumanity")}</p> 136 + <p>{t("player.scraping.items.pending")}</p> 110 137 </div> 111 138 ) : null} 112 139 <div
+82 -23
src/pages/parts/settings/ConnectionsPart.tsx
··· 9 9 10 10 import { Button } from "@/components/buttons/Button"; 11 11 import { Toggle } from "@/components/buttons/Toggle"; 12 + import { BackendSelector } from "@/components/form/BackendSelector"; 12 13 import { Dropdown } from "@/components/form/Dropdown"; 13 14 import { Icon, Icons } from "@/components/Icon"; 14 15 import { SettingsCard } from "@/components/layout/SettingsCard"; ··· 184 185 function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) { 185 186 const { t } = useTranslation(); 186 187 const user = useAuthStore(); 188 + const config = conf(); 189 + const availableBackends = 190 + config.BACKEND_URLS.length > 0 191 + ? config.BACKEND_URLS 192 + : config.BACKEND_URL 193 + ? [config.BACKEND_URL] 194 + : []; 195 + const currentBackendUrl = 196 + backendUrl ?? (availableBackends.length > 0 ? availableBackends[0] : null); 197 + const [pendingBackendUrl, setPendingBackendUrl] = useState<string | null>( 198 + currentBackendUrl, 199 + ); 200 + const confirmationModal = useModal("backend-change-confirmation"); 201 + 202 + const handleBackendSelect = (url: string | null) => { 203 + if (!user.account) { 204 + // No account - just update without confirmation 205 + setBackendUrl(url); 206 + setPendingBackendUrl(url); 207 + } else if (url !== currentBackendUrl) { 208 + // User is logged in and changing backend - show confirmation 209 + setPendingBackendUrl(url); 210 + confirmationModal.show(); 211 + } else { 212 + // Same backend - just update 213 + setBackendUrl(url); 214 + setPendingBackendUrl(url); 215 + } 216 + }; 217 + 218 + const handleConfirmChange = () => { 219 + setBackendUrl(pendingBackendUrl); 220 + confirmationModal.hide(); 221 + }; 222 + 187 223 return ( 188 - <SettingsCard> 189 - <div className="flex justify-between items-center gap-4"> 224 + <> 225 + <SettingsCard> 190 226 <div className="my-3"> 191 227 <p className="text-white font-bold mb-3"> 192 228 {t("settings.connections.server.label")} ··· 211 247 </div> 212 248 )} 213 249 </div> 214 - <div> 215 - <Toggle 216 - onClick={() => setBackendUrl((s) => (s === null ? "" : null))} 217 - enabled={backendUrl !== null} 218 - /> 219 - </div> 220 - </div> 221 - {backendUrl !== null ? ( 222 - <> 223 - <Divider marginClass="my-6 px-8 box-content -mx-8" /> 224 - <p className="text-white font-bold mb-3"> 225 - {t("settings.connections.server.urlLabel")} 226 - </p> 227 - <AuthInputBox 228 - onChange={setBackendUrl} 229 - value={backendUrl ?? ""} 230 - placeholder="https://" 231 - /> 232 - </> 233 - ) : null} 234 - </SettingsCard> 250 + {(availableBackends.length > 0 || currentBackendUrl) && ( 251 + <> 252 + <Divider marginClass="my-6 px-8 box-content -mx-8" /> 253 + <p className="text-white font-bold mb-3"> 254 + {t("settings.connections.server.selectBackend")} 255 + </p> 256 + {availableBackends.length > 0 ? ( 257 + <BackendSelector 258 + selectedUrl={currentBackendUrl} 259 + onSelect={handleBackendSelect} 260 + availableUrls={availableBackends} 261 + showCustom 262 + /> 263 + ) : ( 264 + <AuthInputBox 265 + onChange={setBackendUrl} 266 + value={backendUrl ?? ""} 267 + placeholder="https://" 268 + /> 269 + )} 270 + </> 271 + )} 272 + </SettingsCard> 273 + {user.account && ( 274 + <Modal id={confirmationModal.id}> 275 + <ModalCard> 276 + <Heading2 className="!mt-0 !mb-4"> 277 + {t("settings.connections.server.changeWarningTitle")} 278 + </Heading2> 279 + <Paragraph className="!mt-1 !mb-6"> 280 + {t("settings.connections.server.changeWarning")} 281 + </Paragraph> 282 + <div className="flex justify-end gap-3"> 283 + <Button theme="secondary" onClick={confirmationModal.hide}> 284 + {t("settings.connections.server.cancel")} 285 + </Button> 286 + <Button theme="purple" onClick={handleConfirmChange}> 287 + {t("settings.connections.server.confirm")} 288 + </Button> 289 + </div> 290 + </ModalCard> 291 + </Modal> 292 + )} 293 + </> 235 294 ); 236 295 } 237 296
-14
src/pages/parts/util/WarningPart.tsx
··· 1 - import { Icon, Icons } from "@/components/Icon"; 2 - import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; 3 - 4 - export function WarningPart(props: { children: React.ReactNode }) { 5 - return ( 6 - <div className="flex flex-col justify-center items-center h-screen text-center font-medium"> 7 - <BlurEllipsis /> 8 - <Icon className="text-type-danger text-2xl" icon={Icons.WARNING} /> 9 - <div className="max-w-[19rem] mt-3 mb-12 text-type-secondary"> 10 - {props.children} 11 - </div> 12 - </div> 13 - ); 14 - }
+19 -5
src/setup/config.ts
··· 18 18 NORMAL_ROUTER: boolean; 19 19 BACKEND_URL: string; 20 20 DISALLOWED_IDS: string; 21 - TURNSTILE_KEY: string; 22 21 CDN_REPLACEMENTS: string; 23 22 HAS_ONBOARDING: string; 24 23 ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string; ··· 50 49 PROXY_URLS: string[]; 51 50 M3U8_PROXY_URLS: string[]; 52 51 BACKEND_URL: string | null; 52 + BACKEND_URLS: string[]; 53 53 DISALLOWED_IDS: string[]; 54 - TURNSTILE_KEY: string | null; 55 54 CDN_REPLACEMENTS: Array<string[]>; 56 55 HAS_ONBOARDING: boolean; 57 56 ALLOW_AUTOPLAY: boolean; ··· 88 87 NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, 89 88 BACKEND_URL: import.meta.env.VITE_BACKEND_URL, 90 89 DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, 91 - TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, 92 90 CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, 93 91 HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, 94 92 ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, ··· 140 138 "https://docs.pstream.mov/extension", 141 139 ), 142 140 ONBOARDING_PROXY_INSTALL_LINK: getKey("ONBOARDING_PROXY_INSTALL_LINK"), 143 - BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL), 141 + BACKEND_URLS: getKey("BACKEND_URL", BACKEND_URL) 142 + ? getKey("BACKEND_URL", BACKEND_URL) 143 + .split(",") 144 + .map((v) => v.trim()) 145 + .filter((v) => v.length > 0) 146 + : [], 147 + BACKEND_URL: (() => { 148 + const backendUrlValue = getKey("BACKEND_URL", BACKEND_URL); 149 + if (!backendUrlValue) return backendUrlValue; 150 + if (backendUrlValue.includes(",")) { 151 + const urls = backendUrlValue 152 + .split(",") 153 + .map((v) => v.trim()) 154 + .filter((v) => v.length > 0); 155 + return urls.length > 0 ? urls[0] : backendUrlValue; 156 + } 157 + return backendUrlValue; 158 + })(), 144 159 TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"), 145 160 PROXY_URLS: getKey("CORS_PROXY_URL", "") 146 161 .split(",") ··· 153 168 NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true", 154 169 HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", 155 170 ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true", 156 - TURNSTILE_KEY: getKey("TURNSTILE_KEY"), 157 171 DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") 158 172 .split(",") 159 173 .map((v) => v.trim())
-48
src/stores/turnstile/index.tsx
··· 1 - import { Turnstile } from "@marsidev/react-turnstile"; 2 - import classNames from "classnames"; 3 - import { useRef } from "react"; 4 1 import { create } from "zustand"; 5 2 import { immer } from "zustand/middleware/immer"; 6 3 7 4 import { reportCaptchaSolve } from "@/backend/helpers/report"; 8 - import { conf } from "@/setup/config"; 9 5 10 6 export interface TurnstileStore { 11 7 isInWidget: boolean; ··· 84 80 throw err; 85 81 } 86 82 } 87 - 88 - export function TurnstileProvider(props: { 89 - isInPopout?: boolean; 90 - onUpdateShow?: (show: boolean) => void; 91 - }) { 92 - const siteKey = conf().TURNSTILE_KEY; 93 - const idRef = useRef<string | null>(null); 94 - const setTurnstile = useTurnstileStore((s) => s.setTurnstile); 95 - const processToken = useTurnstileStore((s) => s.processToken); 96 - if (!siteKey) return null; 97 - return ( 98 - <div 99 - className={classNames({ 100 - hidden: !props.isInPopout, 101 - })} 102 - > 103 - <Turnstile 104 - siteKey={siteKey} 105 - options={{ 106 - refreshExpired: "never", 107 - theme: "light", 108 - }} 109 - onWidgetLoad={(widgetId) => { 110 - idRef.current = widgetId; 111 - setTurnstile(widgetId, "mwturnstile", !!props.isInPopout); 112 - }} 113 - onError={() => { 114 - const id = idRef.current; 115 - if (!id) return; 116 - processToken(null, id); 117 - }} 118 - onSuccess={(token) => { 119 - const id = idRef.current; 120 - if (!id) return; 121 - processToken(token, id); 122 - props.onUpdateShow?.(false); 123 - }} 124 - onBeforeInteractive={() => { 125 - props.onUpdateShow?.(true); 126 - }} 127 - /> 128 - </div> 129 - ); 130 - }
+1 -7
src/utils/proxyUrls.ts
··· 2 2 import { useAuthStore } from "@/stores/auth"; 3 3 4 4 const originalUrls = conf().PROXY_URLS; 5 - const types = ["proxy", "api"] as const; 5 + const types = ["proxy"] as const; 6 6 7 7 type ParsedUrlType = (typeof types)[number]; 8 8 ··· 73 73 export function getM3U8ProxyUrls(): string[] { 74 74 return conf().M3U8_PROXY_URLS; 75 75 } 76 - 77 - export function getProviderApiUrls() { 78 - return getParsedUrls() 79 - .filter((v) => v.type === "api") 80 - .map((v) => v.url); 81 - }