User-Wunsch: die Links eines Pakets parallel umwandeln statt seriell (single-flight),
damit sich die Download-Slots schneller fuellen. Gewaehlt: parallel ueber mehrere
Accounts (ein Login je Account laeuft parallel), NIE zwei gleichzeitig auf demselben
Account (Mega-Debrid-Sperr-Risiko).
- MegaWebFallback: globale Einzel-Warteschlange (this.queue) -> Warteschlange PRO Account
(this.queues: Map<login, Promise>). Gleicher Account serialisiert (kein Doppel-Login,
kein Hammern), verschiedene Accounts parallel. Key vor runExclusive berechnet.
- debrid.ts unrestrictWithAccounts: megaDebridInFlight zaehlt die LAUFENDE Tiefe pro
Account (Map<`${id}:${mode}`, number>). Kandidaten werden nach aufsteigender Tiefe
stabil sortiert (cursorOrder als Gleichstand-Tiebreak): gleichzeitige Aufloesungen
greifen den am wenigsten belegten Account -> auch bei mehr Links als Accounts
gleichmaessige Verteilung (4 Accounts, 8 parallel -> 2 je Account), statt sich hinter
dem Cursor-Account zu stauen. Sequenziell (alles Tiefe 0) bleibt es klebrig beim warmen
Account. add/inc vor dem try, dec/cleanup im finally (kein Leak).
- classifyAccountFailure: "Queue-Timeout" (lokaler Eigen-Stau) gibt jetzt cooldownMs:0 —
der warme Account wird nicht mehr faelschlich fuer Eigen-Stau mit Cooldown bestraft.
Vor Release adversarial per Multi-Agent-Workflow geprueft (4 Lenses + Verify). Der Review
fand genau die Ueberzahl-Stau-Schwaeche (binaeres belegt/frei staute >Accounts-Links hinter
einem Account) — daraufhin auf Tiefen-Zaehlung umgestellt. Sperr-Risiko strukturell
ausgeschlossen (per-Account-Queue serialisiert unabhaengig vom Set). 4 neue Tests
(gleicher Account 1 Login, verschiedene Accounts parallel, 4 gleichzeitig->4 Accounts,
8/2-Ueberzahl->4/4 ausgewogen). 813 Tests, tsc=6, self-check + build ok.
319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { MegaWebFallback } from "../src/main/mega-web-fallback";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
describe("mega-web-fallback", () => {
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("MegaWebFallback class", () => {
|
|
it("returns null when credentials are empty", async () => {
|
|
const fallback = new MegaWebFallback(() => ({ login: "", password: "" }));
|
|
const result = await fallback.unrestrict("https://mega.debrid/test");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
|
|
let fetchCallCount = 0;
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const urlStr = String(url);
|
|
fetchCallCount += 1;
|
|
|
|
if (urlStr.includes("form=login")) {
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
|
|
if (urlStr.includes("page=debrideur")) {
|
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
}
|
|
|
|
if (urlStr.includes("form=debrid")) {
|
|
return new Response(`
|
|
<div class="acp-box">
|
|
<h3>Link: https://mega.debrid/link1</h3>
|
|
<a href="javascript:processDebrid(1,'secretcode123',0)">Download</a>
|
|
</div>
|
|
`, { status: 200 });
|
|
}
|
|
|
|
if (urlStr.includes("ajax=debrid")) {
|
|
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
|
|
}
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
|
|
|
const result = await fallback.unrestrict("https://mega.debrid/link1");
|
|
expect(result).not.toBeNull();
|
|
expect(result?.directUrl).toBe("https://mega.direct/123");
|
|
expect(result?.fileName).toBe("link1");
|
|
expect(fetchCallCount).toBe(4);
|
|
});
|
|
|
|
it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => {
|
|
let ajaxCalls = 0;
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const urlStr = String(url);
|
|
if (urlStr.includes("form=login")) {
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
if (urlStr.includes("page=debrideur")) {
|
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
}
|
|
if (urlStr.includes("form=debrid")) {
|
|
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
|
|
}
|
|
if (urlStr.includes("ajax=debrid")) {
|
|
ajaxCalls += 1;
|
|
return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
|
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
|
expect(ajaxCalls).toBe(1);
|
|
});
|
|
|
|
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
|
|
let ajaxCalls = 0;
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const urlStr = String(url);
|
|
if (urlStr.includes("form=login")) {
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
if (urlStr.includes("page=debrideur")) {
|
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
}
|
|
if (urlStr.includes("form=debrid")) {
|
|
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
|
|
}
|
|
if (urlStr.includes("ajax=debrid")) {
|
|
ajaxCalls += 1;
|
|
return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
|
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
|
expect(ajaxCalls).toBe(0);
|
|
});
|
|
|
|
it("logs in with the per-account credentials passed to unrestrict, not the default", async () => {
|
|
const loginsUsed: string[] = [];
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => {
|
|
const urlStr = String(url);
|
|
if (urlStr.includes("form=login")) {
|
|
const params = new URLSearchParams(String(opts?.body ?? ""));
|
|
loginsUsed.push(params.get("login") || "");
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
if (urlStr.includes("page=debrideur")) {
|
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
}
|
|
if (urlStr.includes("form=debrid")) {
|
|
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
|
|
}
|
|
if (urlStr.includes("ajax=debrid")) {
|
|
return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
|
|
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
|
|
expect(result?.directUrl).toBe("https://mega.direct/ok");
|
|
expect(loginsUsed).toContain("account2");
|
|
expect(loginsUsed).not.toContain("defaultacc");
|
|
});
|
|
|
|
it("throws if login fails to set cookie", async () => {
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const urlStr = String(url);
|
|
if (urlStr.includes("form=login")) {
|
|
const headers = new Headers();
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
|
|
|
|
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
|
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
|
|
});
|
|
|
|
it("throws if login verify check fails (no form found)", async () => {
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const urlStr = String(url);
|
|
if (urlStr.includes("form=login")) {
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
if (urlStr.includes("page=debrideur")) {
|
|
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
|
|
|
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
|
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
|
|
});
|
|
|
|
it("returns null if generation fails to find a code", async () => {
|
|
let callCount = 0;
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const urlStr = String(url);
|
|
callCount++;
|
|
if (urlStr.includes("form=login")) {
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
if (urlStr.includes("page=debrideur")) {
|
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
}
|
|
if (urlStr.includes("form=debrid")) {
|
|
return new Response(`<div>No links here</div>`, { status: 200 });
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
|
const result = await fallback.unrestrict("http://mega.debrid/file");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("serialisiert gleichzeitige Umwandlungen auf DEMSELBEN Account (kein Doppel-Login)", async () => {
|
|
let loginCount = 0;
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const u = String(url);
|
|
if (u.includes("form=login")) {
|
|
loginCount += 1;
|
|
await new Promise((r) => setTimeout(r, 15));
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=c; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
|
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
|
|
const [r1, r2] = await Promise.all([
|
|
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "same", password: "pw" }),
|
|
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "same", password: "pw" })
|
|
]);
|
|
expect(r1?.directUrl).toBe("https://mega.direct/ok");
|
|
expect(r2?.directUrl).toBe("https://mega.direct/ok");
|
|
// Serialisiert auf demselben Account → der zweite nutzt die gecachte Session, kein zweiter Login.
|
|
expect(loginCount).toBe(1);
|
|
});
|
|
|
|
it("wandelt auf VERSCHIEDENEN Accounts parallel um (Logins laufen gleichzeitig)", async () => {
|
|
let activeLogins = 0;
|
|
let maxActiveLogins = 0;
|
|
const bothInFlight = new Promise<void>((resolve) => {
|
|
const check = setInterval(() => { if (activeLogins >= 2) { clearInterval(check); resolve(); } }, 5);
|
|
});
|
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
const u = String(url);
|
|
if (u.includes("form=login")) {
|
|
activeLogins += 1;
|
|
maxActiveLogins = Math.max(maxActiveLogins, activeLogins);
|
|
await bothInFlight;
|
|
activeLogins -= 1;
|
|
const headers = new Headers();
|
|
headers.append("set-cookie", "session=c; path=/");
|
|
return new Response("", { headers, status: 200 });
|
|
}
|
|
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
|
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
|
return new Response("Not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const fallback = new MegaWebFallback(() => ({ login: "d", password: "p" }));
|
|
const [r1, r2] = await Promise.all([
|
|
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "acc1", password: "p" }),
|
|
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "acc2", password: "p" })
|
|
]);
|
|
expect(r1?.directUrl).toBe("https://mega.direct/ok");
|
|
expect(r2?.directUrl).toBe("https://mega.direct/ok");
|
|
// Verschiedene Accounts → beide Logins gleichzeitig in-flight (sonst haengt es am bothInFlight-Barrier).
|
|
expect(maxActiveLogins).toBe(2);
|
|
}, 10000);
|
|
|
|
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");
|
|
}, 200);
|
|
|
|
try {
|
|
await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
});
|
|
});
|
|
});
|