diff --git a/package-lock.json b/package-lock.json index 7de7024..e5b3fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.1.20", + "version": "1.1.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.1.20", + "version": "1.1.21", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 314bf43..688a96e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.1.20", + "version": "1.1.21", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/scripts/debrid_service_smoke.ts b/scripts/debrid_service_smoke.ts index fb2a8ff..6d23470 100644 --- a/scripts/debrid_service_smoke.ts +++ b/scripts/debrid_service_smoke.ts @@ -10,7 +10,8 @@ const links = [ const settings = { ...defaultSettings(), token: process.env.RD_TOKEN || "", - megaToken: process.env.MEGA_TOKEN || "", + megaLogin: process.env.MEGA_LOGIN || "", + megaPassword: process.env.MEGA_PASSWORD || "", bestToken: process.env.BEST_TOKEN || "", allDebridToken: process.env.ALLDEBRID_TOKEN || "", providerPrimary: "alldebrid" as const, @@ -19,8 +20,8 @@ const settings = { autoProviderFallback: true }; -if (!settings.token && !settings.megaToken && !settings.bestToken && !settings.allDebridToken) { - console.error("No provider tokens set. Use RD_TOKEN/MEGA_TOKEN/BEST_TOKEN/ALLDEBRID_TOKEN."); +if (!settings.token && !(settings.megaLogin && settings.megaPassword) && !settings.bestToken && !settings.allDebridToken) { + console.error("No provider credentials set. Use RD_TOKEN or MEGA_LOGIN+MEGA_PASSWORD or BEST_TOKEN or ALLDEBRID_TOKEN."); process.exit(1); } diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 556851a..c2dfcbd 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -46,7 +46,12 @@ export class AppController { } private hasAnyProviderToken(settings: AppSettings): boolean { - return Boolean(settings.token.trim() || settings.megaToken.trim() || settings.bestToken.trim() || settings.allDebridToken.trim()); + return Boolean( + settings.token.trim() + || (settings.megaLogin.trim() && settings.megaPassword.trim()) + || settings.bestToken.trim() + || settings.allDebridToken.trim() + ); } public onState: ((snapshot: UiSnapshot) => void) | null = null; diff --git a/src/main/constants.ts b/src/main/constants.ts index bc07863..4e7771f 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -3,7 +3,7 @@ import os from "node:os"; import { AppSettings } from "../shared/types"; export const APP_NAME = "Debrid Download Manager"; -export const APP_VERSION = "1.1.20"; +export const APP_VERSION = "1.1.21"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; @@ -28,7 +28,6 @@ export function defaultSettings(): AppSettings { const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); return { token: "", - megaToken: "", megaLogin: "", megaPassword: "", bestToken: "", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index d378ae9..715ef0d 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,10 +1,8 @@ import { AppSettings, DebridProvider } from "../shared/types"; -import { createHash } from "node:crypto"; import { REQUEST_RETRIES } from "./constants"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; -const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php"; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; @@ -195,135 +193,31 @@ function buildBestDebridRequests(link: string, token: string): BestDebridRequest } class MegaDebridClient { - private token: string; - private megaWebUnrestrict?: MegaWebUnrestrictor; - public constructor(token: string, megaWebUnrestrict?: MegaWebUnrestrictor) { - this.token = token; + public constructor(megaWebUnrestrict?: MegaWebUnrestrictor) { this.megaWebUnrestrict = megaWebUnrestrict; } - private normalizeMegaCandidates(link: string): string[] { - const result = new Set(); - const trimmed = link.trim(); - if (trimmed) { - result.add(trimmed); - } - - try { - const parsed = new URL(trimmed); - const host = parsed.hostname.toLowerCase(); - if (host.includes("rapidgator.net")) { - const parts = parsed.pathname.split("/").filter(Boolean); - const fileIdx = parts.findIndex((part) => part.toLowerCase() === "file"); - if (fileIdx >= 0 && parts[fileIdx + 1]) { - const hash = parts[fileIdx + 1]; - result.add(`https://rapidgator.net/file/${hash}`); - result.add(`http://rapidgator.net/file/${hash}`); - if (parts[fileIdx + 2]) { - const name = parts[fileIdx + 2].replace(/\.html$/i, ""); - result.add(`https://rapidgator.net/file/${hash}/${name}.html`); - result.add(`http://rapidgator.net/file/${hash}/${name}.html`); - } - } - } - } catch { - // ignore malformed URL - } - - return [...result]; - } - - private async requestMega(link: string, includePasswordField: boolean, useGetLinkParam: boolean): Promise { - const url = `${MEGA_DEBRID_API}?action=getLink&token=${encodeURIComponent(this.token)}${useGetLinkParam ? `&link=${encodeURIComponent(link)}` : ""}`; - const body = new URLSearchParams(); - if (!useGetLinkParam) { - body.set("link", link); - } - if (includePasswordField) { - body.set("password", createHash("md5").update("").digest("hex")); - } - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "RD-Node-Downloader/1.1.17" - }, - body - }); - const text = await response.text(); - const payload = asRecord(parseJson(text)); - - if (!response.ok) { - throw new Error(parseError(response.status, text, payload)); - } - - const responseCode = pickString(payload, ["response_code"]); - if (responseCode && responseCode.toLowerCase() !== "ok") { - throw new Error(pickString(payload, ["response_text"]) || responseCode); - } - - const directUrl = pickString(payload, ["debridLink", "download", "link"]); - if (!directUrl) { - throw new Error("Mega-Debrid Antwort ohne debridLink"); - } - - const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(link); - const fileSize = pickNumber(payload, ["filesize", "size"]); - return { - fileName, - directUrl, - fileSize, - retriesUsed: 0 - }; - } - public async unrestrictLink(link: string): Promise { + if (!this.megaWebUnrestrict) { + throw new Error("Mega-Web-Fallback nicht verfügbar"); + } let lastError = ""; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { - try { - const candidates = this.normalizeMegaCandidates(link); - const variants = [ - { includePasswordField: false, useGetLinkParam: false }, - { includePasswordField: true, useGetLinkParam: false }, - { includePasswordField: false, useGetLinkParam: true }, - { includePasswordField: true, useGetLinkParam: true } - ]; - - for (const candidate of candidates) { - for (const variant of variants) { - try { - const out = await this.requestMega(candidate, variant.includePasswordField, variant.useGetLinkParam); - out.retriesUsed = attempt - 1; - return out; - } catch (error) { - lastError = compactErrorText(error); - } - } - } - - if (/token error|vip_end/i.test(lastError)) { - throw new Error(lastError); - } - - if (/UNRESTRICTING_ERROR_1/i.test(lastError) && this.megaWebUnrestrict) { - const web = await this.megaWebUnrestrict(link); - if (web?.directUrl) { - web.retriesUsed = attempt - 1; - return web; - } - } - } catch (error) { + const web = await this.megaWebUnrestrict(link).catch((error) => { lastError = compactErrorText(error); - if (attempt >= REQUEST_RETRIES) { - break; - } + return null; + }); + if (web?.directUrl) { + web.retriesUsed = attempt - 1; + return web; + } + if (attempt < REQUEST_RETRIES) { await sleep(retryDelay(attempt)); } } - throw new Error(lastError || "Mega-Debrid Unrestrict fehlgeschlagen"); + throw new Error(lastError || "Mega-Web Unrestrict fehlgeschlagen"); } } @@ -608,8 +502,7 @@ export class DebridService { const attempts: string[] = []; for (const provider of order) { - const token = this.getProviderToken(provider).trim(); - if (!token) { + if (!this.isProviderConfigured(provider)) { continue; } configuredFound = true; @@ -618,7 +511,7 @@ export class DebridService { } try { - const result = await this.unrestrictViaProvider(provider, link, token); + const result = await this.unrestrictViaProvider(provider, link); return { ...result, provider, @@ -630,35 +523,35 @@ export class DebridService { } if (!configuredFound) { - throw new Error("Kein Debrid-Provider konfiguriert (API-Key fehlt)"); + throw new Error("Kein Debrid-Provider konfiguriert"); } throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`); } - private getProviderToken(provider: DebridProvider): string { + private isProviderConfigured(provider: DebridProvider): boolean { if (provider === "realdebrid") { - return this.settings.token; + return Boolean(this.settings.token.trim()); } if (provider === "megadebrid") { - return this.settings.megaToken; + return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim()); } if (provider === "alldebrid") { - return this.settings.allDebridToken; + return Boolean(this.settings.allDebridToken.trim()); } - return this.settings.bestToken; + return Boolean(this.settings.bestToken.trim()); } - private async unrestrictViaProvider(provider: DebridProvider, link: string, token: string): Promise { + private async unrestrictViaProvider(provider: DebridProvider, link: string): Promise { if (provider === "realdebrid") { return this.realDebridClient.unrestrictLink(link); } if (provider === "megadebrid") { - return new MegaDebridClient(token, this.options.megaWebUnrestrict).unrestrictLink(link); + return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link); } if (provider === "alldebrid") { return this.allDebridClient.unrestrictLink(link); } - return new BestDebridClient(token).unrestrictLink(link); + return new BestDebridClient(this.settings.bestToken).unrestrictLink(link); } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 001ce2b..525c89d 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -417,7 +417,7 @@ export class DownloadManager extends EventEmitter { this.stateEmitTimer = setTimeout(() => { this.stateEmitTimer = null; this.emit("state", this.getSnapshot()); - }, 140); + }, 260); } private pruneSpeedEvents(now: number): void { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0e1eca5..8bddbe9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { DragEvent, ReactElement, useEffect, useMemo, useState } from "react"; +import { DragEvent, ReactElement, useEffect, useMemo, useRef, useState } from "react"; import type { AppSettings, DebridProvider, DownloadItem, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types"; type Tab = "collector" | "downloads" | "settings"; @@ -6,7 +6,6 @@ type Tab = "collector" | "downloads" | "settings"; const emptySnapshot = (): UiSnapshot => ({ settings: { token: "", - megaToken: "", megaLogin: "", megaPassword: "", bestToken: "", @@ -80,6 +79,8 @@ export function App(): ReactElement { const [linksRaw, setLinksRaw] = useState(""); const [statusToast, setStatusToast] = useState(""); const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); + const latestStateRef = useRef(null); + const stateFlushTimerRef = useRef | null>(null); useEffect(() => { let unsubscribe: (() => void) | null = null; @@ -93,9 +94,23 @@ export function App(): ReactElement { } }); unsubscribe = window.rd.onStateUpdate((state) => { - setSnapshot(state); + latestStateRef.current = state; + if (stateFlushTimerRef.current) { + return; + } + stateFlushTimerRef.current = setTimeout(() => { + stateFlushTimerRef.current = null; + if (latestStateRef.current) { + setSnapshot(latestStateRef.current); + latestStateRef.current = null; + } + }, 220); }); return () => { + if (stateFlushTimerRef.current) { + clearTimeout(stateFlushTimerRef.current); + stateFlushTimerRef.current = null; + } if (unsubscribe) { unsubscribe(); } @@ -171,6 +186,7 @@ export function App(): ReactElement { if (files.length === 0) { return; } + await window.rd.updateSettings(settingsDraft); const result = await window.rd.addContainers(files); setStatusToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); setTimeout(() => setStatusToast(""), 2200); @@ -186,6 +202,7 @@ export function App(): ReactElement { if (dlc.length === 0) { return; } + await window.rd.updateSettings(settingsDraft); const result = await window.rd.addContainers(dlc); setStatusToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); setTimeout(() => setStatusToast(""), 2200); @@ -309,18 +326,12 @@ export function App(): ReactElement { value={settingsDraft.token} onChange={(event) => setText("token", event.target.value)} /> - - setText("megaToken", event.target.value)} - /> - + setText("megaLogin", event.target.value)} /> - + { globalThis.fetch = originalFetch; + vi.restoreAllMocks(); }); describe("debrid service", () => { - it("falls back to Mega-Debrid when Real-Debrid fails", async () => { + it("falls back to Mega web when Real-Debrid fails", async () => { const settings = { ...defaultSettings(), token: "rd-token", - megaToken: "mega-token", + megaLogin: "user", + megaPassword: "pass", bestToken: "", providerPrimary: "realdebrid" as const, providerSecondary: "megadebrid" as const, @@ -29,26 +31,29 @@ describe("debrid service", () => { headers: { "Content-Type": "application/json" } }); } - if (url.includes("mega-debrid.eu/api.php?action=getLink")) { - return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega.example/file.bin" }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } return new Response("not-found", { status: 404 }); }) as typeof fetch; - const service = new DebridService(settings); + const megaWeb = vi.fn(async () => ({ + fileName: "file.bin", + directUrl: "https://mega-web.example/file.bin", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const result = await service.unrestrictLink("https://rapidgator.net/file/example.part1.rar.html"); expect(result.provider).toBe("megadebrid"); - expect(result.directUrl).toBe("https://mega.example/file.bin"); + expect(result.directUrl).toBe("https://mega-web.example/file.bin"); + expect(megaWeb).toHaveBeenCalledTimes(1); }); it("does not fallback when auto fallback is disabled", async () => { const settings = { ...defaultSettings(), token: "rd-token", - megaToken: "mega-token", + megaLogin: "user", + megaPassword: "pass", providerPrimary: "realdebrid" as const, providerSecondary: "megadebrid" as const, providerTertiary: "bestdebrid" as const, @@ -60,21 +65,25 @@ describe("debrid service", () => { if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { return new Response("traffic exhausted", { status: 429 }); } - return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega.example/file.bin" }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); + return new Response("not-found", { status: 404 }); }) as typeof fetch; - const service = new DebridService(settings); + const megaWeb = vi.fn(async () => ({ + fileName: "unused.bin", + directUrl: "https://unused", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow(); + expect(megaWeb).toHaveBeenCalledTimes(0); }); it("supports BestDebrid auth query fallback", async () => { const settings = { ...defaultSettings(), token: "", - megaToken: "", bestToken: "best-token", providerPrimary: "bestdebrid" as const, providerSecondary: "realdebrid" as const, @@ -109,7 +118,6 @@ describe("debrid service", () => { const settings = { ...defaultSettings(), token: "", - megaToken: "", bestToken: "", allDebridToken: "ad-token", providerPrimary: "alldebrid" as const, @@ -143,52 +151,46 @@ describe("debrid service", () => { expect(result.fileSize).toBe(4096); }); - it("retries Mega-Debrid with alternate request variants", async () => { + it("uses Mega web path exclusively", async () => { const settings = { ...defaultSettings(), token: "", - megaToken: "mega-token", bestToken: "", allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", providerPrimary: "megadebrid" as const, providerSecondary: "megadebrid" as const, providerTertiary: "megadebrid" as const, autoProviderFallback: true }; - let calls = 0; - globalThis.fetch = (async (input: RequestInfo | URL): Promise => { - calls += 1; - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("mega-debrid.eu/api.php?action=getLink") && !url.includes("&link=")) { - return new Response(JSON.stringify({ response_code: "UNRESTRICTING_ERROR_1", response_text: "UNRESTRICTING_ERROR_1" }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - if (url.includes("mega-debrid.eu/api.php?action=getLink") && url.includes("&link=")) { - return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega.example/file2.bin", filename: "file2.bin" }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - return new Response("not-found", { status: 404 }); - }) as typeof fetch; + const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; - const service = new DebridService(settings); - const result = await service.unrestrictLink("https://rapidgator.net/file/abc/name.part1.rar.html"); + const megaWeb = vi.fn(async () => ({ + fileName: "from-web.rar", + directUrl: "https://www11.unrestrict.link/download/file/abc/from-web.rar", + fileSize: null, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/abc/from-web.rar.html"); expect(result.provider).toBe("megadebrid"); - expect(result.fileName).toBe("file2.bin"); - expect(calls).toBeGreaterThan(1); + expect(result.directUrl).toContain("unrestrict.link/download/file/"); + expect(megaWeb).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(0); }); - it("respects provider selection and does not append hidden fallback providers", async () => { + it("respects provider selection and does not append hidden providers", async () => { const settings = { ...defaultSettings(), token: "", - megaToken: "mega-token", bestToken: "", allDebridToken: "ad-token", + megaLogin: "user", + megaPassword: "pass", providerPrimary: "megadebrid" as const, providerSecondary: "megadebrid" as const, providerTertiary: "megadebrid" as const, @@ -198,12 +200,6 @@ describe("debrid service", () => { let allDebridCalls = 0; globalThis.fetch = (async (input: RequestInfo | URL): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("mega-debrid.eu/api.php?action=getLink")) { - return new Response(JSON.stringify({ response_code: "error", response_text: "host unavailable" }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } if (url.includes("api.alldebrid.com/v4/link/unlock")) { allDebridCalls += 1; return new Response(JSON.stringify({ status: "success", data: { link: "https://alldebrid.example/file.bin" } }), { @@ -214,7 +210,8 @@ describe("debrid service", () => { return new Response("not-found", { status: 404 }); }) as typeof fetch; - const service = new DebridService(settings); + const megaWeb = vi.fn(async () => null); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(); expect(allDebridCalls).toBe(0); });