add realdebrid setup

This commit is contained in:
Pas 2025-06-09 17:46:19 -06:00
parent 29c412707c
commit 715c26e6ab
9 changed files with 378 additions and 8 deletions

View file

@ -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",

View file

@ -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;

View file

@ -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,

View file

@ -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}
/>

View file

@ -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<Status>("unset");
const statusMap: Record<Status, StatusCircleProps["type"]> = {
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 (
<div className="mt-6">
<SettingsCard>
<div className="flex justify-between items-center gap-4">
<div className="my-3">
<p className="text-white font-bold mb-3">
{t("settings.connections.realdebrid.title", "Real Debrid API")}
</p>
<p className="max-w-[30rem] font-medium">
{t(
"settings.connections.realdebrid.description",
"Enter your Real Debrid API key to access premium sources.",
)}
</p>
</div>
<div>
<Toggle onClick={toggleExpanded} enabled={isExpanded} />
</div>
</div>
{isExpanded ? (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<p className="text-white font-bold mb-3">
{t("settings.connections.realdebrid.tokenLabel", "API Key")}
</p>
<div className="flex items-center w-full">
<StatusCircle type={statusMap[status]} className="mx-2 mr-4" />
<AuthInputBox
onChange={(newToken) => {
setRealDebridKey(newToken);
}}
value={realDebridKey ?? ""}
placeholder="API Key"
passwordToggleable
className="flex-grow"
/>
</div>
{status === "error" && (
<p className="text-type-danger mt-4">
{t(
"settings.connections.realdebrid.status.failure",
"Failed to connect to Real Debrid. Please check your API key.",
)}
</p>
)}
{status === "api_down" && (
<p className="text-type-danger mt-4">
{t(
"settings.connections.realdebrid.status.api_down",
"Real Debrid API is currently unavailable. Please try again later.",
)}
</p>
)}
{status === "invalid_token" && (
<p className="text-type-danger mt-4">
{t(
"settings.connections.realdebrid.status.invalid_token",
"Invalid API key or non-premium account. Real Debrid requires a premium account.",
)}
</p>
)}
</>
) : null}
</SettingsCard>
</div>
);
}
return null;
}
function Item(props: { title: string; children: React.ReactNode }) {
@ -474,6 +595,7 @@ export function OnboardingPage() {
)}
</div>
{/* <RealDebridSetup /> */}
<FEDAPISetup />
</BiggerCenterContainer>
</MinimalPageLayout>

View file

@ -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<SetStateAction<string | null>>;
}
interface RealDebridKeyProps {
realDebridKey: string | null;
setRealDebridKey: Dispatch<SetStateAction<string | null>>;
}
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<Status>("unset");
const statusMap: Record<Status, StatusCircleProps["type"]> = {
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 (
<SettingsCard>
<div className="flex justify-between items-center gap-4">
<div className="my-3">
<p className="text-white font-bold mb-3">{t("realdebrid.title")}</p>
<p className="max-w-[30rem] font-medium">
{t("realdebrid.description")}
</p>
<MwLink>
<a
href="https://real-debrid.com/"
target="_blank"
rel="noreferrer"
>
real-debrid.com
</a>
</MwLink>
</div>
<div className="flex items-center gap-3">
{!hasExtension && <Icon icon={Icons.UNPLUG} className="text-lg" />}
<Toggle
onClick={() =>
hasExtension
? setRealDebridKey((s) => (s === null ? "" : null))
: null
}
enabled={realDebridKey !== null && hasExtension}
/>
</div>
</div>
{realDebridKey !== null && hasExtension ? (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<p className="text-white font-bold mb-3">
{t("realdebrid.tokenLabel")}
</p>
<div className="flex items-center w-full">
<StatusCircle type={statusMap[status]} className="mx-2 mr-4" />
<AuthInputBox
onChange={(newToken) => {
setRealDebridKey(newToken);
}}
value={realDebridKey ?? ""}
placeholder="ABC123..."
passwordToggleable
className="flex-grow"
/>
</div>
{status === "error" && (
<p className="text-type-danger mt-4">
{t("realdebrid.status.failure")}
</p>
)}
{status === "api_down" && (
<p className="text-type-danger mt-4">
{t("realdebrid.status.api_down")}
</p>
)}
{status === "invalid_token" && (
<p className="text-type-danger mt-4">
{t("realdebrid.status.invalid_token")}
</p>
)}
</>
) : null}
</SettingsCard>
);
}
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}
/>
<RealDebridKeyEdit
realDebridKey={props.realDebridKey}
setRealDebridKey={props.setRealDebridKey}
/>
<FebboxKeyEdit
febboxKey={props.febboxKey}
setFebboxKey={props.setFebboxKey}

View file

@ -6,7 +6,7 @@ import { useNavigate } from "react-router-dom";
import { useAsync } from "react-use";
import { isExtensionActive } from "@/backend/extension/messaging";
import { singularProxiedFetch } from "@/backend/helpers/fetch";
import { proxiedFetch, singularProxiedFetch } from "@/backend/helpers/fetch";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
@ -74,6 +74,7 @@ type SetupData = {
proxy: Status;
defaultProxy: Status;
febboxKeyTest?: Status;
realDebridKeyTest?: Status;
};
function testProxy(url: string) {
@ -174,9 +175,59 @@ export async function testFebboxKey(febboxKey: string | null): Promise<Status> {
return "api_down";
}
export async function testRealDebridKey(
realDebridKey: string | null,
): Promise<Status> {
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<SetupData> => {
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")}
</SetupCheckList>
{conf().ALLOW_REAL_DEBRID_KEY && (
<SetupCheckList status={setupStates.realDebridKeyTest || "unset"}>
Real Debrid token
</SetupCheckList>
)}
{conf().ALLOW_FEBBOX_KEY && (
<SetupCheckList status={setupStates.febboxKeyTest || "unset"}>
Febbox UI token

View file

@ -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<keyof Config, undefined | string> = {
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 <beforeA>:<afterA>,<beforeB>:<afterB>
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(",")

View file

@ -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",