From 2b322968d94f5d8ac30b3af7db260ab0398c19a9 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 6 Mar 2026 05:42:34 +0100 Subject: [PATCH] Add Real-Debrid web-login as alternative to manual API token Co-Authored-By: Claude Opus 4.6 --- src/main/app-controller.ts | 14 ++ src/main/constants.ts | 1 + src/main/debrid.ts | 15 +- src/main/download-manager.ts | 6 +- src/main/main.ts | 4 + src/main/realdebrid-web.ts | 366 +++++++++++++++++++++++++++++++++++ src/main/storage.ts | 2 + src/preload/preload.ts | 1 + src/renderer/App.tsx | 23 ++- src/shared/ipc.ts | 1 + src/shared/preload-api.ts | 1 + src/shared/types.ts | 1 + tests/debrid.test.ts | 72 +++++++ tests/storage.test.ts | 16 ++ 14 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 src/main/realdebrid-web.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index d7fa66d..33ffad4 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -23,6 +23,7 @@ import { fetchAllDebridHostInfo } from "./debrid"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; import { AllDebridWebFallback } from "./all-debrid-web"; +import { RealDebridWebFallback } from "./realdebrid-web"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; @@ -45,6 +46,8 @@ export class AppController { private megaWebFallback: MegaWebFallback; + private realDebridWebFallback: RealDebridWebFallback; + private allDebridWebFallback: AllDebridWebFallback; private lastUpdateCheck: UpdateCheckResult | null = null; @@ -66,10 +69,12 @@ export class AppController { login: this.settings.megaLogin, password: this.settings.megaPassword })); + this.realDebridWebFallback = new RealDebridWebFallback(() => this.settings.rememberToken); this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), + realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), onHistoryEntry: (entry: HistoryEntry) => { addHistoryEntry(this.storagePaths, entry); @@ -109,6 +114,7 @@ export class AppController { private hasAnyProviderToken(settings: AppSettings): boolean { return Boolean( settings.token.trim() + || settings.realDebridUseWebLogin || (settings.megaLogin.trim() && settings.megaPassword.trim()) || settings.bestToken.trim() || settings.allDebridUseWebLogin @@ -168,6 +174,9 @@ export class AppController { saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); if (previousSettings.rememberToken && !this.settings.rememberToken) { + void this.realDebridWebFallback.clearSessions().catch((error) => { + logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); + }); void this.allDebridWebFallback.clearSessions().catch((error) => { logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); }); @@ -175,6 +184,10 @@ export class AppController { return this.settings; } + public async openRealDebridLoginWindow(): Promise { + await this.realDebridWebFallback.openLoginWindow(); + } + public async openAllDebridLoginWindow(): Promise { await this.allDebridWebFallback.openLoginWindow(); } @@ -379,6 +392,7 @@ export class AppController { abortActiveUpdateDownload(); this.manager.prepareForShutdown(); this.megaWebFallback.dispose(); + this.realDebridWebFallback.dispose(); this.allDebridWebFallback.dispose(); shutdownSessionLog(); logger.info("App beendet"); diff --git a/src/main/constants.ts b/src/main/constants.ts index e6b3008..958f333 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -41,6 +41,7 @@ export function defaultSettings(): AppSettings { const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); return { token: "", + realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", bestToken: "", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 1d02582..662555a 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -31,10 +31,12 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink { export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; +export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; interface DebridServiceOptions { megaWebUnrestrict?: MegaWebUnrestrictor; allDebridWebUnrestrict?: AllDebridWebUnrestrictor; + realDebridWebUnrestrict?: RealDebridWebUnrestrictor; } function cloneSettings(settings: AppSettings): AppSettings { @@ -1433,6 +1435,10 @@ export class DebridService { return clean; } + private shouldUseRealDebridWeb(settings: AppSettings): boolean { + return Boolean(settings.realDebridUseWebLogin && this.options.realDebridWebUnrestrict); + } + private shouldUseAllDebridWeb(settings: AppSettings): boolean { return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict); } @@ -1556,7 +1562,7 @@ export class DebridService { private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean { if (provider === "realdebrid") { - return Boolean(settings.token.trim()); + return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim()); } if (provider === "megadebrid") { return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict); @@ -1575,6 +1581,13 @@ export class DebridService { private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise { if (provider === "realdebrid") { + if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) { + const result = await this.options.realDebridWebUnrestrict(link, signal); + if (!result) { + throw new Error("Real-Debrid-Web-Fallback nicht verfügbar"); + } + return result; + } return new RealDebridClient(settings.token).unrestrictLink(link, signal); } if (provider === "megadebrid") { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index b9c6007..fba67ae 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -38,7 +38,7 @@ function releaseTlsSkip(): void { } } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; -import { AllDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid"; +import { AllDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; @@ -158,6 +158,7 @@ type HistoryEntryCallback = (entry: HistoryEntry) => void; type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; allDebridWebUnrestrict?: AllDebridWebUnrestrictor; + realDebridWebUnrestrict?: RealDebridWebUnrestrictor; invalidateMegaSession?: () => void; onHistoryEntry?: HistoryEntryCallback; }; @@ -951,7 +952,8 @@ export class DownloadManager extends EventEmitter { this.storagePaths = storagePaths; this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict, - allDebridWebUnrestrict: options.allDebridWebUnrestrict + allDebridWebUnrestrict: options.allDebridWebUnrestrict, + realDebridWebUnrestrict: options.realDebridWebUnrestrict }); this.invalidateMegaSessionFn = options.invalidateMegaSession; this.onHistoryEntryCallback = options.onHistoryEntry; diff --git a/src/main/main.ts b/src/main/main.ts index f79a625..45fe7de 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -446,6 +446,10 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { + await controller.openRealDebridLoginWindow(); + }); + ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => { await controller.openAllDebridLoginWindow(); }); diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts new file mode 100644 index 0000000..79df52b --- /dev/null +++ b/src/main/realdebrid-web.ts @@ -0,0 +1,366 @@ +import { BrowserWindow, session } from "electron"; +import { UnrestrictedLink } from "./realdebrid"; +import { filenameFromUrl, sleep } from "./utils"; +import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; + +const RD_BASE_URL = "https://real-debrid.com"; +const RD_LOGIN_URL = RD_BASE_URL; +const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`; +const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`; +const RD_PERSISTENT_PARTITION = "persist:realdebrid-web"; +const RD_TRANSIENT_PARTITION = "realdebrid-web"; +const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; + +type GenerateOutcome = + | { kind: "success"; value: UnrestrictedLink } + | { kind: "login_required" }; + +function abortError(): Error { + return new Error("aborted:realdebrid-web"); +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!signal) { + return timeoutSignal; + } + return AbortSignal.any([signal, timeoutSignal]); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(); + } +} + +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) { + throw abortError(); + } + + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(abortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function parseJson(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +function looksLikeHtmlResponse(text: string): boolean { + const trimmed = text.trim(); + return trimmed.startsWith(" = Promise.resolve(); + + private loginWindow: BrowserWindow | null = null; + + private loginWindowPartition = ""; + + private cachedToken = ""; + + private cachedTokenAt = 0; + + private getRememberSession: () => boolean; + + public constructor(getRememberSession: () => boolean) { + this.getRememberSession = getRememberSession; + } + + public async unrestrict(link: string, signal?: AbortSignal): Promise { + const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000); + return this.runExclusive(async () => { + throwIfAborted(overallSignal); + if (!String(link || "").trim()) { + return null; + } + + const initial = await this.generate(link, overallSignal); + if (initial.kind === "success") { + return initial.value; + } + return this.waitForLoginAndGenerate(link, overallSignal); + }, overallSignal); + } + + public async openLoginWindow(): Promise { + const window = await this.ensureLoginWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + } + + public async clearSessions(): Promise { + this.disposeLoginWindow(); + this.cachedToken = ""; + this.cachedTokenAt = 0; + for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) { + const currentSession = session.fromPartition(partition); + try { + await currentSession.clearStorageData({ + storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] + }); + } catch { + // ignore + } + try { + await currentSession.clearCache(); + } catch { + // ignore + } + } + } + + public dispose(): void { + this.disposeLoginWindow(); + } + + private getPartition(): string { + return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION; + } + + private disposeLoginWindow(): void { + const current = this.loginWindow; + this.loginWindow = null; + this.loginWindowPartition = ""; + if (current && !current.isDestroyed()) { + current.close(); + } + } + + private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { + const queuedAt = Date.now(); + const queueWaitTimeoutMs = 90_000; + const guardedJob = async (): Promise => { + throwIfAborted(signal); + const waited = Date.now() - queuedAt; + if (waited > queueWaitTimeoutMs) { + throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); + } + return job(); + }; + const run = this.queue.then(guardedJob, guardedJob); + this.queue = run.then(() => undefined, () => undefined); + return run; + } + + private async ensureLoginWindow(): Promise { + const partition = this.getPartition(); + const existing = this.loginWindow; + if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) { + return existing; + } + + if (existing && !existing.isDestroyed()) { + existing.close(); + } + + const window = new BrowserWindow({ + width: 1120, + height: 900, + minWidth: 980, + minHeight: 760, + autoHideMenuBar: true, + title: "Real-Debrid Web-Login", + webPreferences: { + partition, + contextIsolation: true, + nodeIntegration: false + } + }); + window.setMenuBarVisibility(false); + window.webContents.setUserAgent(RD_USER_AGENT); + window.on("closed", () => { + if (this.loginWindow === window) { + this.loginWindow = null; + this.loginWindowPartition = ""; + } + }); + this.loginWindow = window; + this.loginWindowPartition = partition; + await window.loadURL(RD_LOGIN_URL); + return window; + } + + private async extractApiToken(signal?: AbortSignal): Promise { + throwIfAborted(signal); + + // Return cached token if fresh (max 30 min) + if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) { + return this.cachedToken; + } + + const currentSession = session.fromPartition(this.getPartition()); + const response = await currentSession.fetch(RD_APITOKEN_URL, { + headers: { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Referer: RD_BASE_URL + "/", + "User-Agent": RD_USER_AGENT + }, + signal: withTimeoutSignal(signal, 30_000) + }); + const html = await response.text(); + + if (!response.ok || response.status === 403) { + return null; + } + + // Real-Debrid sets the token via inline JS: + // document.querySelectorAll('input[name=private_token]')[0].value = 'TOKEN_HERE'; + const tokenMatch = html.match(/private_token['"]\]\[0\]\.value\s*=\s*'([^']+)'/); + if (tokenMatch && tokenMatch[1]) { + this.cachedToken = tokenMatch[1]; + this.cachedTokenAt = Date.now(); + return this.cachedToken; + } + + // Fallback: look for the token in an input value attribute + const inputMatch = html.match(/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/); + if (inputMatch && inputMatch[1]) { + this.cachedToken = inputMatch[1]; + this.cachedTokenAt = Date.now(); + return this.cachedToken; + } + + return null; + } + + private async generate(link: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + + const token = await this.extractApiToken(signal); + if (!token) { + return { kind: "login_required" }; + } + + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + throwIfAborted(signal); + try { + const body = new URLSearchParams({ link }); + const response = await fetch(RD_UNRESTRICT_API, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": RD_USER_AGENT + }, + body, + signal: withTimeoutSignal(signal, 30_000) + }); + + const text = await response.text(); + + if (response.status === 401 || response.status === 403) { + // Token expired or revoked — invalidate cache + this.cachedToken = ""; + this.cachedTokenAt = 0; + return { kind: "login_required" }; + } + + if (!response.ok) { + if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal); + continue; + } + throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`); + } + + if (looksLikeHtmlResponse(text)) { + throw new Error("Real-Debrid Web lieferte HTML statt JSON"); + } + + const payload = parseJson(text.trim()); + if (!payload) { + throw new Error("Ungültige JSON-Antwort von Real-Debrid Web"); + } + + const directUrl = String(payload.download || payload.link || "").trim(); + if (!directUrl) { + throw new Error("Real-Debrid Web: Antwort ohne Download-URL"); + } + + const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link); + const fileSizeRaw = Number(payload.filesize ?? NaN); + return { + kind: "success", + value: { + directUrl, + fileName, + fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null, + retriesUsed: attempt - 1 + } + }; + } catch (error) { + if (signal?.aborted) { + throw abortError(); + } + if (attempt >= REQUEST_RETRIES) { + throw error; + } + await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal); + } + } + + throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen"); + } + + private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise { + const window = await this.ensureLoginWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + + const startedAt = Date.now(); + while (Date.now() - startedAt < 10 * 60 * 1000) { + throwIfAborted(signal); + if (window.isDestroyed()) { + throw new Error("Real-Debrid Web-Login abgebrochen"); + } + + const outcome = await this.generate(link, signal); + if (outcome.kind === "success") { + if (!window.isDestroyed()) { + window.close(); + } + return outcome.value; + } + + await sleepWithSignal(1_500, signal); + } + + throw new Error("Real-Debrid Web-Login Timeout"); + } +} diff --git a/src/main/storage.ts b/src/main/storage.ts index 25bb84d..4aed400 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -107,6 +107,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { const defaults = defaultSettings(); const normalized: AppSettings = { token: asText(settings.token), + realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), megaLogin: asText(settings.megaLogin), megaPassword: asText(settings.megaPassword), bestToken: asText(settings.bestToken), @@ -201,6 +202,7 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { return { ...settings, token: "", + realDebridUseWebLogin: settings.realDebridUseWebLogin, megaLogin: "", megaPassword: "", bestToken: "", diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 88377a0..6429f9c 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -52,6 +52,7 @@ const api: ElectronApi = { importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), + openRealDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7a0232e..0409473 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -63,7 +63,7 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { - token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", + token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", @@ -982,7 +982,7 @@ export function App(): ReactElement { const configuredProviders = useMemo(() => { const list: DebridProvider[] = []; - if (settingsDraft.token.trim()) { + if (settingsDraft.realDebridUseWebLogin || settingsDraft.token.trim()) { list.push("realdebrid"); } if (settingsDraft.megaLogin.trim() && settingsDraft.megaPassword.trim()) { @@ -995,7 +995,7 @@ export function App(): ReactElement { list.push("alldebrid"); } return list; - }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken, settingsDraft.allDebridUseWebLogin]); + }, [settingsDraft.token, settingsDraft.realDebridUseWebLogin, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken, settingsDraft.allDebridUseWebLogin]); // DDownload is a direct file hoster (not a debrid service) and is used automatically // for ddownload.com/ddl.to URLs. It counts as a configured account but does not @@ -1136,6 +1136,16 @@ export function App(): ReactElement { }); }; + const onOpenRealDebridLogin = async (): Promise => { + await performQuickAction(async () => { + await persistDraftSettings(); + await window.rd.openRealDebridLogin(); + showToast("Real-Debrid Login-Fenster geöffnet", 2200); + }, (error) => { + showToast(`Real-Debrid Login fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + const onOpenAllDebridLogin = async (): Promise => { await performQuickAction(async () => { await persistDraftSettings(); @@ -2820,6 +2830,13 @@ export function App(): ReactElement {

Accounts

setText("token", e.target.value)} /> + + {settingsDraft.realDebridUseWebLogin && ( + <> +
Beim ersten Link oder über den Button unten öffnet sich ein Real-Debrid-Browserfenster. Der Login läuft dort manuell über die Website.
+ + + )} setText("megaLogin", e.target.value)} /> diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5066b95..41433e5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -34,6 +34,7 @@ export const IPC_CHANNELS = { IMPORT_BACKUP: "app:import-backup", OPEN_LOG: "app:open-log", OPEN_SESSION_LOG: "app:open-session-log", + OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", RETRY_EXTRACTION: "queue:retry-extraction", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 3c2ffa5..bf42ba5 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -47,6 +47,7 @@ export interface ElectronApi { importBackup: () => Promise<{ restored: boolean; message: string }>; openLog: () => Promise; openSessionLog: () => Promise; + openRealDebridLogin: () => Promise; openAllDebridLogin: () => Promise; getAllDebridHostInfo: () => Promise; retryExtraction: (packageId: string) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 0aeb438..5a107a4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -38,6 +38,7 @@ export interface DownloadStats { export interface AppSettings { token: string; + realDebridUseWebLogin: boolean; megaLogin: string; megaPassword: string; bestToken: string; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 0fcd169..9928708 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -325,6 +325,78 @@ describe("debrid service", () => { await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i); }); + it("uses Real-Debrid web path when enabled", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + realDebridUseWebLogin: true, + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const realDebridWeb = vi.fn(async () => ({ + fileName: "from-rd-web.rar", + directUrl: "https://download.real-debrid.com/d/example/from-rd-web.rar", + fileSize: 5678, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html"); + expect(result.provider).toBe("realdebrid"); + expect(result.directUrl).toContain("real-debrid.com/d/"); + expect(result.fileSize).toBe(5678); + expect(realDebridWeb).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); + + it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => { + const settings = { + ...defaultSettings(), + token: "", + realDebridUseWebLogin: true, + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://rapidgator.net/file/missing-rd-web")).rejects.toThrow(/nicht konfiguriert/i); + }); + + it("falls back to API token when Real-Debrid web login is disabled", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + realDebridUseWebLogin: false, + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + globalThis.fetch = (async () => new Response(JSON.stringify({ + download: "https://download.real-debrid.com/d/test/file.rar", + filename: "file.rar", + filesize: 9999 + }), { + status: 200, + headers: { "Content-Type": "application/json" } + })) as typeof fetch; + + const realDebridWeb = vi.fn(async () => null); + const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/test.rar.html"); + expect(result.provider).toBe("realdebrid"); + expect(realDebridWeb).not.toHaveBeenCalled(); + }); + it("treats MegaDebrid as not configured when web fallback callback is unavailable", async () => { const settings = { ...defaultSettings(), diff --git a/tests/storage.test.ts b/tests/storage.test.ts index f164bdd..cc23bdd 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -155,6 +155,22 @@ describe("settings storage", () => { expect(normalized.archivePasswordList).toBe("one\ntwo\nthree"); }); + it("defaults Real-Debrid web login to disabled and normalizes the flag", () => { + expect(defaultSettings().realDebridUseWebLogin).toBe(false); + + const normalizedEnabled = normalizeSettings({ + ...defaultSettings(), + realDebridUseWebLogin: 1 as unknown as boolean + }); + expect(normalizedEnabled.realDebridUseWebLogin).toBe(true); + + const normalizedDisabled = normalizeSettings({ + ...defaultSettings(), + realDebridUseWebLogin: 0 as unknown as boolean + }); + expect(normalizedDisabled.realDebridUseWebLogin).toBe(false); + }); + it("defaults AllDebrid web login to disabled and normalizes the flag", () => { expect(defaultSettings().allDebridUseWebLogin).toBe(false);