From faece1cf26d3e62f0438f5fcf7137654749dd896 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 6 Mar 2026 10:51:31 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(megadebrid):=20add=20API=20mod?= =?UTF-8?q?e=20with=20toggle=20and=20provider=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Mega-Debrid API support (connectUser + getLink endpoints) - API mode preferred by default, with automatic web fallback on failure - User toggle "Mega-Debrid bevorzugt über API" in settings UI - Provider labels now show source: "Mega-Debrid (API)" or "Mega-Debrid (Web)" - sourceLabel propagated through all provider result paths - API session token cached for 20 minutes with auto-invalidation - Remove megaWebUnrestrict requirement for Mega-Debrid provider config Co-Authored-By: Claude Opus 4.6 --- src/main/constants.ts | 1 + src/main/debrid.ts | 144 ++++++++++++++++++++++++++++++++++++++--- src/main/realdebrid.ts | 1 + src/main/storage.ts | 1 + src/renderer/App.tsx | 3 +- src/shared/types.ts | 1 + tests/debrid.test.ts | 13 ++-- 7 files changed, 150 insertions(+), 14 deletions(-) diff --git a/src/main/constants.ts b/src/main/constants.ts index 958f333..378d92f 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -44,6 +44,7 @@ export function defaultSettings(): AppSettings { realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", + megaDebridPreferApi: true, bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 662555a..726f3f1 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -12,6 +12,8 @@ const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1"; +const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php"; + const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1"; const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i; @@ -159,6 +161,14 @@ function parseJson(text: string): unknown { } } +function parseJsonSafe(text: string): Record | null { + const parsed = parseJson(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; +} + function pickString(payload: Record | null, keys: string[]): string { if (!payload) { return ""; @@ -659,11 +669,105 @@ function buildBestDebridRequests(link: string, token: string): BestDebridRequest class MegaDebridClient { private megaWebUnrestrict?: MegaWebUnrestrictor; - public constructor(megaWebUnrestrict?: MegaWebUnrestrictor) { + private login: string; + + private password: string; + + private preferApi: boolean; + + private static cachedApiToken = ""; + + private static cachedApiTokenAt = 0; + + public constructor(login: string, password: string, preferApi: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) { + this.login = login; + this.password = password; + this.preferApi = preferApi; this.megaWebUnrestrict = megaWebUnrestrict; } - public async unrestrictLink(link: string, signal?: AbortSignal): Promise { + private async connectApi(signal?: AbortSignal): Promise { + // Return cached token if fresh (max 20 min) + if (MegaDebridClient.cachedApiToken && Date.now() - MegaDebridClient.cachedApiTokenAt < 20 * 60 * 1000) { + return MegaDebridClient.cachedApiToken; + } + + const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`; + const response = await fetch(url, { + headers: { "User-Agent": DEBRID_USER_AGENT }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + if (!response.ok) { + return null; + } + const payload = parseJsonSafe(text); + if (!payload || payload.response_code !== "ok") { + return null; + } + const token = String(payload.token || "").trim(); + if (!token) { + return null; + } + MegaDebridClient.cachedApiToken = token; + MegaDebridClient.cachedApiTokenAt = Date.now(); + return token; + } + + private async unrestrictViaApi(link: string, signal?: AbortSignal): Promise { + const token = await this.connectApi(signal); + if (!token) { + return null; + } + + const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": DEBRID_USER_AGENT + }, + body: new URLSearchParams({ link }), + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + if (!response.ok) { + // Token might be invalid, clear cache + if (response.status === 401 || response.status === 403) { + MegaDebridClient.cachedApiToken = ""; + MegaDebridClient.cachedApiTokenAt = 0; + } + return null; + } + const payload = parseJsonSafe(text); + if (!payload || payload.response_code !== "ok") { + // Token expired — clear cache for next attempt + if (payload && String(payload.response_code || "").includes("token")) { + MegaDebridClient.cachedApiToken = ""; + MegaDebridClient.cachedApiTokenAt = 0; + } + const errorText = String(payload?.response_text || "").trim(); + if (errorText) { + throw new Error(`Mega-Debrid API: ${errorText}`); + } + return null; + } + + const directUrl = String(payload.debridLink || "").trim(); + if (!directUrl) { + return null; + } + const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link); + return { + directUrl, + fileName, + fileSize: null, + retriesUsed: 0, + sourceLabel: "API" + }; + } + + private async unrestrictViaWeb(link: string, signal?: AbortSignal): Promise { if (!this.megaWebUnrestrict) { throw new Error("Mega-Web-Fallback nicht verfügbar"); } @@ -681,6 +785,7 @@ class MegaDebridClient { } if (web?.directUrl) { web.retriesUsed = attempt - 1; + web.sourceLabel = "Web"; return web; } if (web && !web.directUrl) { @@ -699,6 +804,29 @@ class MegaDebridClient { } throw new Error(String(lastError || "Mega-Web Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise { + if (this.preferApi && this.login.trim() && this.password.trim()) { + // API mode: try API first, fall back to web on failure + try { + const apiResult = await this.unrestrictViaApi(link, signal); + if (apiResult) { + logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`); + return apiResult; + } + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + logger.warn(`Mega-Debrid API fehlgeschlagen, versuche Web-Fallback: ${errorText}`); + } + return this.unrestrictViaWeb(link, signal); + } + + // Web mode only + return this.unrestrictViaWeb(link, signal); + } } class BestDebridClient { @@ -1454,7 +1582,7 @@ export class DebridService { return { ...result, provider: "onefichier", - providerLabel: PROVIDER_LABELS["onefichier"] + providerLabel: PROVIDER_LABELS["onefichier"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") }; } catch (error) { const errorText = compactErrorText(error); @@ -1474,7 +1602,7 @@ export class DebridService { return { ...result, provider: "ddownload", - providerLabel: PROVIDER_LABELS["ddownload"] + providerLabel: PROVIDER_LABELS["ddownload"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") }; } catch (error) { const errorText = compactErrorText(error); @@ -1509,7 +1637,7 @@ export class DebridService { ...result, fileName, provider: primary, - providerLabel: PROVIDER_LABELS[primary] + providerLabel: PROVIDER_LABELS[primary] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") }; } catch (error) { const errorText = compactErrorText(error); @@ -1542,7 +1670,7 @@ export class DebridService { ...result, fileName, provider, - providerLabel: PROVIDER_LABELS[provider] + providerLabel: PROVIDER_LABELS[provider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") }; } catch (error) { const errorText = compactErrorText(error); @@ -1565,7 +1693,7 @@ export class DebridService { return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim()); } if (provider === "megadebrid") { - return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict); + return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); } if (provider === "alldebrid") { return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim()); @@ -1591,7 +1719,7 @@ export class DebridService { return new RealDebridClient(settings.token).unrestrictLink(link, signal); } if (provider === "megadebrid") { - return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link, signal); + return new MegaDebridClient(settings.megaLogin, settings.megaPassword, settings.megaDebridPreferApi, this.options.megaWebUnrestrict).unrestrictLink(link, signal); } if (provider === "alldebrid") { if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 93d27c4..6b1fe85 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -9,6 +9,7 @@ export interface UnrestrictedLink { fileSize: number | null; retriesUsed: number; skipTlsVerify?: boolean; + sourceLabel?: string; } function shouldRetryStatus(status: number): boolean { diff --git a/src/main/storage.ts b/src/main/storage.ts index 4aed400..921e474 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -110,6 +110,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), megaLogin: asText(settings.megaLogin), megaPassword: asText(settings.megaPassword), + megaDebridPreferApi: settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true, bestToken: asText(settings.bestToken), allDebridToken: asText(settings.allDebridToken), allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0409473..24fd6be 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -63,7 +63,7 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { - token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", + token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridPreferApi: true, bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", @@ -2841,6 +2841,7 @@ export function App(): ReactElement { setText("megaLogin", e.target.value)} /> setText("megaPassword", e.target.value)} /> + setText("bestToken", e.target.value)} /> diff --git a/src/shared/types.ts b/src/shared/types.ts index 5a107a4..e48e9a2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,6 +41,7 @@ export interface AppSettings { realDebridUseWebLogin: boolean; megaLogin: string; megaPassword: string; + megaDebridPreferApi: boolean; bestToken: string; allDebridToken: string; allDebridUseWebLogin: boolean; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 9928708..75f406b 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -397,11 +397,11 @@ describe("debrid service", () => { expect(realDebridWeb).not.toHaveBeenCalled(); }); - it("treats MegaDebrid as not configured when web fallback callback is unavailable", async () => { + it("treats MegaDebrid as not configured when no credentials are set", async () => { const settings = { ...defaultSettings(), - megaLogin: "user", - megaPassword: "pass", + megaLogin: "", + megaPassword: "", providerPrimary: "megadebrid" as const, providerSecondary: "none" as const, providerTertiary: "none" as const, @@ -412,7 +412,7 @@ describe("debrid service", () => { await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i); }); - it("uses Mega web path exclusively", async () => { + it("uses Mega web fallback when API fails", async () => { const settings = { ...defaultSettings(), token: "", @@ -426,6 +426,7 @@ describe("debrid service", () => { autoProviderFallback: true }; + // API returns 404 for connectUser → API fails, falls back to web const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); globalThis.fetch = fetchSpy as unknown as typeof fetch; @@ -441,7 +442,6 @@ describe("debrid service", () => { expect(result.provider).toBe("megadebrid"); expect(result.directUrl).toContain("unrestrict.link/download/file/"); expect(megaWeb).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledTimes(0); }); it("aborts Mega web unrestrict when caller signal is cancelled", async () => { @@ -458,6 +458,9 @@ describe("debrid service", () => { autoProviderFallback: false }; + // API connect fails fast → falls through to web fallback + globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; + const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise => new Promise((_, reject) => { const onAbort = (): void => reject(new Error("aborted:mega-web-test")); if (signal?.aborted) {