From 647679f58164c8f87c4d12101678dbac22eced59 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 1 Mar 2026 19:06:34 +0100 Subject: [PATCH] Fix Mega-Web unrestrict hangs and release v1.4.66 --- CHANGELOG.md | 18 +++++ package-lock.json | 4 +- package.json | 2 +- src/main/app-controller.ts | 2 +- src/main/debrid.ts | 4 +- src/main/mega-web-fallback.ts | 129 +++++++++++++++++++++++++++----- tests/debrid.test.ts | 38 ++++++++++ tests/mega-web-fallback.test.ts | 51 +++++++++++++ 8 files changed, 225 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e02776e..471a7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert. +## 1.4.66 - 2026-03-01 + +Hotfix fuer haengende "Link wird umgewandelt"-Faelle (insbesondere Mega-Web-Pfad), bei denen nur ein App-Neustart geholfen hat. + +### Fixes + +- Mega-Web-Unrestrict ist jetzt komplett abort-/timeout-faehig: + - Abort-Signale werden bis in den Mega-Web-Fallback durchgereicht. + - Laufende Polling-/Fetch-Schritte respektieren Stop/Timeout sofort. + - Wartende Jobs in der exklusiven Mega-Web-Queue koennen bei Abort sauber abbrechen. +- Download-Manager kann haengende Unrestrict-Phasen dadurch wieder automatisch per Timeout + Retry aufloesen, statt dauerhaft in "Link wird umgewandelt" zu bleiben. + +### Tests + +- Neue Tests sichern den Fix ab: + - Abort-Weitergabe bei Mega-Web-Unrestrict in `tests/debrid.test.ts`. + - Abort waehrend Mega-Web-Polling in `tests/mega-web-fallback.test.ts`. + ## 1.4.33 - 2026-03-02 Hotfix-Release fuer zwei reale Produktionsprobleme: falsche Gesamt-Statistik bei leerer Queue und stilles DLC-Import-Failure bei Drag-and-Drop. diff --git a/package-lock.json b/package-lock.json index a3862a5..655a290 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.33", + "version": "1.4.66", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.33", + "version": "1.4.66", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index a81ed86..fa9f8f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.65", + "version": "1.4.66", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 57f99fe..b3260ac 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -57,7 +57,7 @@ export class AppController { password: this.settings.megaPassword })); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { - megaWebUnrestrict: (link: string) => this.megaWebFallback.unrestrict(link) + megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal) }); this.manager.on("state", (snapshot: UiSnapshot) => { this.onStateHandler?.(snapshot); diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 94fc53f..c85c559 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -23,7 +23,7 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink { providerLabel: string; } -export type MegaWebUnrestrictor = (link: string) => Promise; +export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; interface DebridServiceOptions { megaWebUnrestrict?: MegaWebUnrestrictor; @@ -488,7 +488,7 @@ class MegaDebridClient { if (signal?.aborted) { throw new Error("aborted:debrid"); } - const web = await this.megaWebUnrestrict(link).catch((error) => { + const web = await this.megaWebUnrestrict(link, signal).catch((error) => { lastError = compactErrorText(error); return null; }); diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index 008511e..4278d08 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -88,6 +88,93 @@ function parseDebridJson(text: string): { link: string; text: string } | null { } } +function abortError(): Error { + return new Error("aborted:mega-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 }); + }); +} + +async function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + throw abortError(); + } + + return new Promise((resolve, reject) => { + let settled = false; + + const onAbort = (): void => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + reject(abortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + + promise.then((value) => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + resolve(value); + }, (error) => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + reject(error); + }); + }); +} + export class MegaWebFallback { private queue: Promise = Promise.resolve(); @@ -101,22 +188,23 @@ export class MegaWebFallback { this.getCredentials = getCredentials; } - public async unrestrict(link: string): Promise { + public async unrestrict(link: string, signal?: AbortSignal): Promise { return this.runExclusive(async () => { + throwIfAborted(signal); const creds = this.getCredentials(); if (!creds.login.trim() || !creds.password.trim()) { return null; } if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) { - await this.login(creds.login, creds.password); + await this.login(creds.login, creds.password, signal); } - const generated = await this.generate(link); + const generated = await this.generate(link, signal); if (!generated) { this.cookie = ""; - await this.login(creds.login, creds.password); - const retry = await this.generate(link); + await this.login(creds.login, creds.password, signal); + const retry = await this.generate(link, signal); if (!retry) { return null; } @@ -134,16 +222,21 @@ export class MegaWebFallback { fileSize: null, retriesUsed: 0 }; - }); + }, signal); } - private async runExclusive(job: () => Promise): Promise { - const run = this.queue.then(job, job); + private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { + const guardedJob = async (): Promise => { + throwIfAborted(signal); + return job(); + }; + const run = this.queue.then(guardedJob, guardedJob); this.queue = run.then(() => undefined, () => undefined); - return run; + return raceWithAbort(run, signal); } - private async login(login: string, password: string): Promise { + private async login(login: string, password: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); const response = await fetch(LOGIN_URL, { method: "POST", headers: { @@ -156,7 +249,7 @@ export class MegaWebFallback { remember: "on" }), redirect: "manual", - signal: AbortSignal.timeout(30000) + signal: withTimeoutSignal(signal, 30000) }); const cookie = parseSetCookieFromHeaders(response.headers); @@ -171,7 +264,7 @@ export class MegaWebFallback { Cookie: cookie, Referer: DEBRID_REFERER }, - signal: AbortSignal.timeout(30000) + signal: withTimeoutSignal(signal, 30000) }); const verifyHtml = await verify.text(); const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml); @@ -183,7 +276,8 @@ export class MegaWebFallback { this.cookieSetAt = Date.now(); } - private async generate(link: string): Promise<{ directUrl: string; fileName: string } | null> { + private async generate(link: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> { + throwIfAborted(signal); const page = await fetch(DEBRID_URL, { method: "POST", headers: { @@ -197,7 +291,7 @@ export class MegaWebFallback { password: "", showLinks: "1" }), - signal: AbortSignal.timeout(30000) + signal: withTimeoutSignal(signal, 30000) }); const html = await page.text(); @@ -207,6 +301,7 @@ export class MegaWebFallback { } for (let attempt = 1; attempt <= 60; attempt += 1) { + throwIfAborted(signal); const res = await fetch(DEBRID_AJAX_URL, { method: "POST", headers: { @@ -219,12 +314,12 @@ export class MegaWebFallback { code, autodl: "0" }), - signal: AbortSignal.timeout(15000) + signal: withTimeoutSignal(signal, 15000) }); const text = (await res.text()).trim(); if (text === "reload") { - await sleep(650); + await sleepWithSignal(650, signal); continue; } if (text === "false") { @@ -238,7 +333,7 @@ export class MegaWebFallback { if (!parsed.link) { if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) { - await sleep(1200); + await sleepWithSignal(1200, signal); continue; } return null; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 29f1a21..486ab79 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -290,6 +290,44 @@ describe("debrid service", () => { expect(fetchSpy).toHaveBeenCalledTimes(0); }); + it("aborts Mega web unrestrict when caller signal is cancelled", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + 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) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + })); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const controller = new AbortController(); + const abortTimer = setTimeout(() => { + controller.abort("test"); + }, 25); + + try { + await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i); + expect(megaWeb).toHaveBeenCalledTimes(1); + expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal); + } finally { + clearTimeout(abortTimer); + } + }); + it("respects provider selection and does not append hidden providers", async () => { const settings = { ...defaultSettings(), diff --git a/tests/mega-web-fallback.test.ts b/tests/mega-web-fallback.test.ts index 91d650d..ce0c766 100644 --- a/tests/mega-web-fallback.test.ts +++ b/tests/mega-web-fallback.test.ts @@ -123,5 +123,56 @@ describe("mega-web-fallback", () => { // Generation fails -> resets cookie -> tries again -> fails again -> returns null expect(result).toBeNull(); }); + + it("aborts pending Mega-Web polling when signal is cancelled", async () => { + globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise => { + const urlStr = String(url); + + if (urlStr.includes("form=login")) { + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return Promise.resolve(new Response("", { headers, status: 200 })); + } + + if (urlStr.includes("page=debrideur")) { + return Promise.resolve(new Response('
', { status: 200 })); + } + + if (urlStr.includes("form=debrid")) { + return Promise.resolve(new Response(` +
+

Link: https://mega.debrid/link2

+ Download +
+ `, { status: 200 })); + } + + if (urlStr.includes("ajax=debrid")) { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + const onAbort = (): void => reject(new Error("aborted:ajax")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + return Promise.resolve(new Response("Not found", { status: 404 })); + }) as unknown as typeof fetch; + + const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort("test"); + }, 30); + + try { + await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i); + } finally { + clearTimeout(timer); + } + }); }); });