Mega-Debrid: Link-Umwandlung parallel ueber mehrere Accounts (per-Account-Queue + Tiefen-Routing)

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.
This commit is contained in:
Sucukdeluxe
2026-06-16 23:36:22 +02:00
parent 232af28715
commit 2c596bbc8d
4 changed files with 186 additions and 14 deletions
+71
View File
@@ -1460,6 +1460,77 @@ describe("debrid service", () => {
expect(usedIds).toEqual(new Array(3).fill(getMegaDebridAccountId("user2")));
}, 30000);
it("verteilt gleichzeitige Umwandlungen auf verschiedene Accounts (parallel, in-flight-Routing)", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
megaDebridPreferApi: false,
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
let active = 0;
let maxActive = 0;
const accountsSeen = new Set<string>();
const allInFlight = new Promise<void>((resolve) => {
const check = setInterval(() => { if (active >= 4) { clearInterval(check); resolve(); } }, 5);
});
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
active += 1;
maxActive = Math.max(maxActive, active);
if (account) accountsSeen.add(account.login);
await allInFlight;
active -= 1;
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const results = await Promise.all([0, 1, 2, 3].map((i) => service.unrestrictLink(`https://rapidgator.net/file/conc-${i}`)));
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
expect(accountsSeen.size).toBe(4);
expect(maxActive).toBe(4);
}, 15000);
it("verteilt Ueberzahl-Umwandlungen (mehr gleichzeitig als Accounts) gleichmaessig statt sie zu stapeln", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
let active = 0;
const callsPerAccount = new Map<string, number>();
const allInFlight = new Promise<void>((resolve) => {
const check = setInterval(() => { if (active >= 8) { clearInterval(check); resolve(); } }, 5);
});
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
active += 1;
if (account) callsPerAccount.set(account.login, (callsPerAccount.get(account.login) ?? 0) + 1);
await allInFlight;
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await Promise.all(Array.from({ length: 8 }, (_unused, i) => service.unrestrictLink(`https://rapidgator.net/file/ov-${i}`)));
// 8 gleichzeitige Aufloesungen auf 2 Accounts → 4 je Account (statt 7/1 beim alten belegt/frei-Set).
expect(callsPerAccount.get("user1")).toBe(4);
expect(callsPerAccount.get("user2")).toBe(4);
}, 15000);
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
const settings = {
...defaultSettings(),
+62
View File
@@ -202,6 +202,68 @@ describe("mega-web-fallback", () => {
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);