From 715c26e6abfb332f17e265ca01f50131640836f0 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 9 Jun 2025 17:46:19 -0600 Subject: [PATCH] add realdebrid setup --- src/assets/locales/en.json | 10 ++ src/backend/accounts/settings.ts | 2 + src/hooks/useSettingsState.ts | 14 ++ src/pages/Settings.tsx | 15 +- src/pages/onboarding/Onboarding.tsx | 126 ++++++++++++++++- src/pages/parts/settings/ConnectionsPart.tsx | 139 ++++++++++++++++++- src/pages/parts/settings/SetupPart.tsx | 68 ++++++++- src/setup/config.ts | 4 + src/stores/preferences/index.tsx | 8 ++ 9 files changed, 378 insertions(+), 8 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 6b6d87cf..e21c5b61 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -977,6 +977,16 @@ "invalid_token": "Failed to fetch a 'VIP' stream. Your token is invalid!" } }, + "realdebrid": { + "title": "Real Debrid (Beta)", + "description": "Enter your Real Debrid API key to access Real Debrid. Extension required.", + "tokenLabel": "API Key", + "status": { + "failure": "Failed to connect to Real Debrid. Please check your API key.", + "api_down": "Real Debrid API is currently unavailable. Please try again later.", + "invalid_token": "Invalid API key or non-premium account. Real Debrid requires a premium account." + } + }, "watchParty": { "status": { "inSync": "In sync", diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index c1897001..88f7b747 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -9,6 +9,7 @@ export interface SettingsInput { defaultSubtitleLanguage?: string; proxyUrls?: string[] | null; febboxKey?: string | null; + realDebridKey?: string | null; enableThumbnails?: boolean; enableAutoplay?: boolean; enableSkipCredits?: boolean; @@ -28,6 +29,7 @@ export interface SettingsResponse { defaultSubtitleLanguage?: string | null; proxyUrls?: string[] | null; febboxKey?: string | null; + realDebridKey?: string | null; enableThumbnails?: boolean; enableAutoplay?: boolean; enableSkipCredits?: boolean; diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index 954367b3..6344f166 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -44,6 +44,7 @@ export function useSettingsState( proxyUrls: string[] | null, backendUrl: string | null, febboxKey: string | null, + realDebridKey: string | null, profile: | { colorA: string; @@ -69,6 +70,12 @@ export function useSettingsState( useDerived(backendUrl); const [febboxKeyState, setFebboxKey, resetFebboxKey, febboxKeyChanged] = useDerived(febboxKey); + const [ + realDebridKeyState, + setRealDebridKey, + resetRealDebridKey, + realDebridKeyChanged, + ] = useDerived(realDebridKey); const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme); const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme); const resetPreviewTheme = useCallback( @@ -162,6 +169,7 @@ export function useSettingsState( resetProxyUrls(); resetBackendUrl(); resetFebboxKey(); + resetRealDebridKey(); resetDeviceName(); resetProfile(); resetEnableThumbnails(); @@ -185,6 +193,7 @@ export function useSettingsState( backendUrlChanged || proxyUrlsChanged || febboxKeyChanged || + realDebridKeyChanged || profileChanged || enableThumbnailsChanged || enableAutoplayChanged || @@ -236,6 +245,11 @@ export function useSettingsState( set: setFebboxKey, changed: febboxKeyChanged, }, + realDebridKey: { + state: realDebridKeyState, + set: setRealDebridKey, + changed: realDebridKeyChanged, + }, profile: { state: profileState, set: setProfileState, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ab020262..c3c26564 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -134,6 +134,9 @@ export function SettingsPage() { const febboxKey = usePreferencesStore((s) => s.febboxKey); const setFebboxKey = usePreferencesStore((s) => s.setFebboxKey); + const realDebridKey = usePreferencesStore((s) => s.realDebridKey); + const setRealDebridKey = usePreferencesStore((s) => s.setRealDebridKey); + const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails); const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails); @@ -195,10 +198,13 @@ export function SettingsPage() { if (settings.febboxKey) { setFebboxKey(settings.febboxKey); } + if (settings.realDebridKey) { + setRealDebridKey(settings.realDebridKey); + } } }; loadSettings(); - }, [account, backendUrl, setFebboxKey]); + }, [account, backendUrl, setFebboxKey, setRealDebridKey]); const state = useSettingsState( activeTheme, @@ -208,6 +214,7 @@ export function SettingsPage() { proxySet, backendUrlSetting, febboxKey, + realDebridKey, account ? account.profile : undefined, enableThumbnails, enableAutoplay, @@ -264,6 +271,7 @@ export function SettingsPage() { state.theme.changed || state.proxyUrls.changed || state.febboxKey.changed || + state.realDebridKey.changed || state.enableThumbnails.changed || state.enableAutoplay.changed || state.enableSkipCredits.changed || @@ -281,6 +289,7 @@ export function SettingsPage() { applicationTheme: state.theme.state, proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null, febboxKey: state.febboxKey.state, + realDebridKey: state.realDebridKey.state, enableThumbnails: state.enableThumbnails.state, enableAutoplay: state.enableAutoplay.state, enableSkipCredits: state.enableSkipCredits.state, @@ -325,6 +334,7 @@ export function SettingsPage() { setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null); setEnableSourceOrder(state.enableSourceOrder.state); setFebboxKey(state.febboxKey.state); + setRealDebridKey(state.realDebridKey.state); setProxyTmdb(state.proxyTmdb.state); setEnableCarouselView(state.enableCarouselView.state); @@ -348,6 +358,7 @@ export function SettingsPage() { backendUrl, setEnableThumbnails, setFebboxKey, + setRealDebridKey, state, setEnableAutoplay, setEnableSkipCredits, @@ -448,6 +459,8 @@ export function SettingsPage() { setProxyUrls={state.proxyUrls.set} febboxKey={state.febboxKey.state} setFebboxKey={state.febboxKey.set} + realDebridKey={state.realDebridKey.state} + setRealDebridKey={state.realDebridKey.set} proxyTmdb={state.proxyTmdb.state} setProxyTmdb={state.proxyTmdb.set} /> diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 46e25767..439d9914 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -41,11 +41,14 @@ import { } from "@/pages/onboarding/utils"; import { PageTitle } from "@/pages/parts/util/PageTitle"; import { conf } from "@/setup/config"; -import { useAuthStore } from "@/stores/auth"; import { usePreferencesStore } from "@/stores/preferences"; import { getProxyUrls } from "@/utils/proxyUrls"; -import { Status, testFebboxKey } from "../parts/settings/SetupPart"; +import { + Status, + testFebboxKey, + testRealDebridKey, +} from "../parts/settings/SetupPart"; async function getFebboxKeyStatus(febboxKey: string | null) { if (febboxKey) { @@ -218,12 +221,130 @@ export function FEDAPISetup() { ); } } + +async function getRealDebridKeyStatus(realDebridKey: string | null) { + if (realDebridKey) { + const status: Status = await testRealDebridKey(realDebridKey); + return status; + } + return "unset"; +} + +export function RealDebridSetup() { + const { t } = useTranslation(); + const realDebridKey = usePreferencesStore((s) => s.realDebridKey); + const setRealDebridKey = usePreferencesStore((s) => s.setRealDebridKey); + + // Initialize isExpanded based on whether realDebridKey has a value + const [isExpanded, setIsExpanded] = useState( + realDebridKey !== null && realDebridKey !== "", + ); + + // Add a separate effect to set the initial state + useEffect(() => { + // If we have a valid key, make sure the section is expanded + if (realDebridKey && realDebridKey.length > 0) { + setIsExpanded(true); + } + }, [realDebridKey]); + + const [status, setStatus] = useState("unset"); + const statusMap: Record = { + error: "error", + success: "success", + unset: "noresult", + api_down: "error", + invalid_token: "error", + }; + + useEffect(() => { + const checkTokenStatus = async () => { + const result = await getRealDebridKeyStatus(realDebridKey); + setStatus(result); + }; + checkTokenStatus(); + }, [realDebridKey]); + + // Toggle handler that preserves the key + const toggleExpanded = () => { + if (isExpanded) { + // Store the key temporarily instead of setting to null + setRealDebridKey(""); + setIsExpanded(false); + } else { + setIsExpanded(true); + } + }; + + if (conf().ALLOW_REAL_DEBRID_KEY) { + return ( +
+ +
+
+

+ {t("settings.connections.realdebrid.title", "Real Debrid API")} +

+

+ {t( + "settings.connections.realdebrid.description", + "Enter your Real Debrid API key to access premium sources.", + )} +

+
+
+ +
+
+ {isExpanded ? ( + <> + +

+ {t("settings.connections.realdebrid.tokenLabel", "API Key")} +

+
+ + { + setRealDebridKey(newToken); + }} + value={realDebridKey ?? ""} + placeholder="API Key" + passwordToggleable + className="flex-grow" + /> +
+ {status === "error" && ( +

+ {t( + "settings.connections.realdebrid.status.failure", + "Failed to connect to Real Debrid. Please check your API key.", + )} +

+ )} + {status === "api_down" && ( +

+ {t( + "settings.connections.realdebrid.status.api_down", + "Real Debrid API is currently unavailable. Please try again later.", + )} +

+ )} + {status === "invalid_token" && ( +

+ {t( + "settings.connections.realdebrid.status.invalid_token", + "Invalid API key or non-premium account. Real Debrid requires a premium account.", + )} +

+ )} ) : null}
); } + return null; } function Item(props: { title: string; children: React.ReactNode }) { @@ -474,6 +595,7 @@ export function OnboardingPage() { )} + {/* */} diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index 33223399..63e65bd2 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -7,6 +7,7 @@ import { } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { isExtensionActive } from "@/backend/extension/messaging"; import { Button } from "@/components/buttons/Button"; import { Toggle } from "@/components/buttons/Toggle"; import { Icon, Icons } from "@/components/Icon"; @@ -23,6 +24,7 @@ import { SetupPart, Status, testFebboxKey, + testRealDebridKey, } from "@/pages/parts/settings/SetupPart"; import { conf } from "@/setup/config"; import { useAuthStore } from "@/stores/auth"; @@ -45,6 +47,11 @@ interface FebboxKeyProps { setFebboxKey: Dispatch>; } +interface RealDebridKeyProps { + realDebridKey: string | null; + setRealDebridKey: Dispatch>; +} + function ProxyEdit({ proxyUrls, setProxyUrls, @@ -368,8 +375,134 @@ function FebboxKeyEdit({ febboxKey, setFebboxKey }: FebboxKeyProps) { } } +async function getRealDebridKeyStatus(realDebridKey: string | null) { + if (realDebridKey) { + const status: Status = await testRealDebridKey(realDebridKey); + return status; + } + return "unset"; +} + +function RealDebridKeyEdit({ + realDebridKey, + setRealDebridKey, +}: RealDebridKeyProps) { + const { t } = useTranslation(); + const user = useAuthStore(); + const preferences = usePreferencesStore(); + const [hasExtension, setHasExtension] = useState(false); + + // Check for extension + useEffect(() => { + isExtensionActive().then(setHasExtension); + }, []); + + // Enable Real Debrid token when account is loaded and we have a token + useEffect(() => { + if (user.account && realDebridKey === null && preferences.realDebridKey) { + setRealDebridKey(preferences.realDebridKey); + } + }, [ + user.account, + realDebridKey, + preferences.realDebridKey, + setRealDebridKey, + ]); + + const [status, setStatus] = useState("unset"); + const statusMap: Record = { + error: "error", + success: "success", + unset: "noresult", + api_down: "error", + invalid_token: "error", + }; + + useEffect(() => { + const checkTokenStatus = async () => { + const result = await getRealDebridKeyStatus(realDebridKey); + setStatus(result); + }; + checkTokenStatus(); + }, [realDebridKey]); + + if (conf().ALLOW_REAL_DEBRID_KEY) { + return ( + +
+
+

{t("realdebrid.title")}

+

+ {t("realdebrid.description")} +

+ + + real-debrid.com + + +
+
+ {!hasExtension && } + + hasExtension + ? setRealDebridKey((s) => (s === null ? "" : null)) + : null + } + enabled={realDebridKey !== null && hasExtension} + /> +
+
+ {realDebridKey !== null && hasExtension ? ( + <> + +

+ {t("realdebrid.tokenLabel")} +

+
+ + { + setRealDebridKey(newToken); + }} + value={realDebridKey ?? ""} + placeholder="ABC123..." + passwordToggleable + className="flex-grow" + /> +
+ {status === "error" && ( +

+ {t("realdebrid.status.failure")} +

+ )} + {status === "api_down" && ( +

+ {t("realdebrid.status.api_down")} +

+ )} + {status === "invalid_token" && ( +

+ {t("realdebrid.status.invalid_token")} +

+ )} + + ) : null} +
+ ); + } + return null; +} + export function ConnectionsPart( - props: BackendEditProps & ProxyEditProps & FebboxKeyProps, + props: BackendEditProps & + ProxyEditProps & + FebboxKeyProps & + RealDebridKeyProps, ) { const { t } = useTranslation(); return ( @@ -387,6 +520,10 @@ export function ConnectionsPart( backendUrl={props.backendUrl} setBackendUrl={props.setBackendUrl} /> + { return "api_down"; } +export async function testRealDebridKey( + realDebridKey: string | null, +): Promise { + if (!realDebridKey) { + return "unset"; + } + + const maxAttempts = 2; + let attempts = 0; + + while (attempts < maxAttempts) { + try { + console.log(`RD API attempt ${attempts + 1}`); + const data = await proxiedFetch( + "https://api.real-debrid.com/rest/1.0/user", + { + method: "GET", + headers: { + Authorization: `Bearer ${realDebridKey}`, + "Content-Type": "application/json", + }, + }, + ); + + // If we have data and it indicates premium status, return success immediately + if (data && typeof data === "object" && data.type === "premium") { + console.log("RD premium status confirmed"); + return "success"; + } + + console.log("RD response did not indicate premium status"); + attempts += 1; + if (attempts === maxAttempts) { + return "invalid_token"; + } + await sleep(3000); + } catch (error) { + console.error("RD API error:", error); + attempts += 1; + if (attempts === maxAttempts) { + return "api_down"; + } + await sleep(3000); + } + } + + return "api_down"; +} + function useIsSetup() { const proxyUrls = useAuthStore((s) => s.proxySet); const febboxKey = usePreferencesStore((s) => s.febboxKey); + const realDebridKey = usePreferencesStore((s) => s.realDebridKey); const { loading, value } = useAsync(async (): Promise => { const extensionStatus: Status = (await isExtensionActive()) ? "success" @@ -192,6 +243,7 @@ function useIsSetup() { } const febboxKeyStatus: Status = await testFebboxKey(febboxKey); + const realDebridKeyStatus: Status = await testRealDebridKey(realDebridKey); return { extension: extensionStatus, @@ -200,20 +252,23 @@ function useIsSetup() { ...(conf().ALLOW_FEBBOX_KEY && { febboxKeyTest: febboxKeyStatus, }), + realDebridKeyTest: realDebridKeyStatus, }; - }, [proxyUrls, febboxKey]); + }, [proxyUrls, febboxKey, realDebridKey]); let globalState: Status = "unset"; if ( value?.extension === "success" || value?.proxy === "success" || - value?.febboxKeyTest === "success" + value?.febboxKeyTest === "success" || + value?.realDebridKeyTest === "success" ) globalState = "success"; if ( value?.proxy === "error" || value?.extension === "error" || - value?.febboxKeyTest === "error" + value?.febboxKeyTest === "error" || + value?.realDebridKeyTest === "error" ) globalState = "error"; @@ -354,6 +409,11 @@ export function SetupPart() { > {t("settings.connections.setup.items.default")} + {conf().ALLOW_REAL_DEBRID_KEY && ( + + Real Debrid token + + )} {conf().ALLOW_FEBBOX_KEY && ( Febbox UI token diff --git a/src/setup/config.ts b/src/setup/config.ts index d2d63db7..137e77c3 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -26,6 +26,7 @@ interface Config { ONBOARDING_PROXY_INSTALL_LINK: string; ALLOW_AUTOPLAY: boolean; ALLOW_FEBBOX_KEY: boolean; + ALLOW_REAL_DEBRID_KEY: boolean; SHOW_AD: boolean; AD_CONTENT_URL: string; TRACK_SCRIPT: string; @@ -38,6 +39,7 @@ export interface RuntimeConfig { DMCA_EMAIL: string | null; TWITTER_LINK: string; TMDB_READ_API_KEY: string | null; + ALLOW_REAL_DEBRID_KEY: boolean; NORMAL_ROUTER: boolean; PROXY_URLS: string[]; M3U8_PROXY_URLS: string[]; @@ -79,6 +81,7 @@ const env: Record = { HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, ALLOW_FEBBOX_KEY: import.meta.env.VITE_ALLOW_FEBBOX_KEY, + ALLOW_REAL_DEBRID_KEY: import.meta.env.VITE_ALLOW_REAL_DEBRID_KEY, SHOW_AD: import.meta.env.VITE_SHOW_AD, AD_CONTENT_URL: import.meta.env.VITE_AD_CONTENT_URL, TRACK_SCRIPT: import.meta.env.VITE_TRACK_SCRIPT, @@ -147,6 +150,7 @@ export function conf(): RuntimeConfig { ) .filter((v) => v.length === 2), // The format is :,: ALLOW_FEBBOX_KEY: getKey("ALLOW_FEBBOX_KEY", "false") === "true", + ALLOW_REAL_DEBRID_KEY: getKey("ALLOW_REAL_DEBRID_KEY", "false") === "true", SHOW_AD: getKey("SHOW_AD", "false") === "true", AD_CONTENT_URL: getKey("AD_CONTENT_URL", "") .split(",") diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 39831aed..bb7859b3 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -15,6 +15,7 @@ export interface PreferencesStore { enableSourceOrder: boolean; proxyTmdb: boolean; febboxKey: string | null; + realDebridKey: string | null; setEnableThumbnails(v: boolean): void; setEnableAutoplay(v: boolean): void; @@ -28,6 +29,7 @@ export interface PreferencesStore { setEnableSourceOrder(v: boolean): void; setProxyTmdb(v: boolean): void; setFebboxKey(v: string | null): void; + setRealDebridKey(v: string | null): void; } export const usePreferencesStore = create( @@ -45,6 +47,7 @@ export const usePreferencesStore = create( enableSourceOrder: false, proxyTmdb: false, febboxKey: null, + realDebridKey: null, setEnableThumbnails(v) { set((s) => { s.enableThumbnails = v; @@ -105,6 +108,11 @@ export const usePreferencesStore = create( s.febboxKey = v; }); }, + setRealDebridKey(v) { + set((s) => { + s.realDebridKey = v; + }); + }, })), { name: "__MW::preferences",