Fix Mega-Web unrestrict hangs and release v1.4.66
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-03-01 19:06:34 +01:00
parent 237bf6731d
commit 647679f581
8 changed files with 225 additions and 23 deletions

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink {
providerLabel: string;
}
export type MegaWebUnrestrictor = (link: string) => Promise<UnrestrictedLink | null>;
export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
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;
});

View File

@ -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<void> {
if (!signal) {
await sleep(ms);
return;
}
if (signal.aborted) {
throw abortError();
}
await new Promise<void>((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<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortError();
}
return new Promise<T>((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<unknown> = Promise.resolve();
@ -101,22 +188,23 @@ export class MegaWebFallback {
this.getCredentials = getCredentials;
}
public async unrestrict(link: string): Promise<UnrestrictedLink | null> {
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
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<T>(job: () => Promise<T>): Promise<T> {
const run = this.queue.then(job, job);
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const guardedJob = async (): Promise<T> => {
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<void> {
private async login(login: string, password: string, signal?: AbortSignal): Promise<void> {
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;

View File

@ -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<never> => 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(),

View File

@ -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<Response> => {
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('<form id="debridForm"></form>', { status: 200 }));
}
if (urlStr.includes("form=debrid")) {
return Promise.resolve(new Response(`
<div class="acp-box">
<h3>Link: https://mega.debrid/link2</h3>
<a href="javascript:processDebrid(1,'secretcode456',0)">Download</a>
</div>
`, { status: 200 }));
}
if (urlStr.includes("ajax=debrid")) {
return new Promise<Response>((_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);
}
});
});
});