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

View File

@ -306,6 +306,13 @@ let megaDebridStickyCount = 0;
// naechsten gewechselt. So bleibt es schnell UND ueber die Zeit kommen alle dran.
export const MEGA_DEBRID_STICKY_LINKS = 25;
// Wie viele Umwandlungen pro Account (key `${id}:${mode}`) GERADE laufen/anstehen. Gleichzeitige
// Aufloesungen waehlen den am WENIGSTEN belegten Account → verschiedene Accounts wandeln parallel
// um (je eigene Queue in MegaWebFallback), und auch bei mehr gleichzeitigen Links als Accounts
// verteilt es sich gleichmaessig statt sich hinter einem Account zu stauen. Tiefe (nicht nur
// belegt/frei), damit ein frueh fertiger Erst-Job einen noch laufenden Folge-Job nicht "frei" meldet.
const megaDebridInFlight = new Map<string, number>();
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
megaDebridEmptyResponseStreaks.set(accountId, streak);
@ -321,6 +328,7 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridEmptyResponseStreaks.clear();
megaDebridRotationCursor = 0;
megaDebridStickyCount = 0;
megaDebridInFlight.clear();
}
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
@ -1923,11 +1931,22 @@ class MegaDebridClient {
// Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander
// folgende Links auf demselben warmen Account laufen statt jeweils neu einzuloggen.
const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length;
const orderedEntries: { account: MegaDebridAccountEntry; idx: number }[] = [];
const cursorOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
for (let step = 0; step < accounts.length; step += 1) {
const idx = (startOffset + step) % accounts.length;
orderedEntries.push({ account: accounts[idx], idx });
cursorOrder.push({ account: accounts[idx], idx });
}
// Parallel ueber mehrere Accounts: nach AKTUELLER Auslastung (in-flight-Tiefe) sortieren —
// am wenigsten belegter Account zuerst, cursorOrder als stabiler Gleichstand-Tiebreak. So
// verteilen sich auch MEHR gleichzeitige Aufloesungen als Accounts gleichmaessig (statt sich
// alle hinter dem Cursor-Account zu stauen). Nichts in-flight (sequenziell) => alle Tiefe 0 =>
// Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account.
const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number =>
megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0;
const orderedEntries = cursorOrder
.map((entry, position) => ({ entry, position }))
.sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position))
.map((wrapped) => wrapped.entry);
for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) {
const entry = orderedEntries[orderPos];
@ -1977,6 +1996,7 @@ class MegaDebridClient {
const testStartedAt = Date.now();
usableAccountSeen = true;
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal);
@ -2078,6 +2098,13 @@ class MegaDebridClient {
next: nextLabel,
link: linkShort
});
} finally {
const remainingInFlight = (megaDebridInFlight.get(cooldownKey) ?? 1) - 1;
if (remainingInFlight <= 0) {
megaDebridInFlight.delete(cooldownKey);
} else {
megaDebridInFlight.set(cooldownKey, remainingInFlight);
}
}
}
@ -2154,6 +2181,13 @@ class MegaDebridClient {
};
}
if (/queue.?timeout/i.test(errorText)) {
// Lokaler Stau (die Queue dieses Accounts war zu lange belegt) — KEIN Signal fuer einen
// ungesunden Account. Kein Cooldown, sonst wuerde der warme Account fuer Eigen-Stau bestraft;
// der Link wird einfach erneut versucht und rotiert dann natuerlich weiter.
return { fatal: false, cooldownMs: 0, message: errorText, category: "temporary" };
}
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
return {
fatal: false,

View File

@ -217,7 +217,11 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Prom
}
export class MegaWebFallback {
private queue: Promise<unknown> = Promise.resolve();
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
private queues = new Map<string, Promise<unknown>>();
private getCredentials: () => MegaCredentials;
@ -233,15 +237,15 @@ export class MegaWebFallback {
account?: { login: string; password: string }
): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 180000);
const creds = (account && account.login.trim() && account.password.trim())
? account
: this.getCredentials();
if (!creds.login.trim() || !creds.password.trim()) {
return null;
}
const key = creds.login.trim().toLowerCase();
return this.runExclusive(async () => {
throwIfAborted(overallSignal);
const creds = (account && account.login.trim() && account.password.trim())
? account
: this.getCredentials();
if (!creds.login.trim() || !creds.password.trim()) {
return null;
}
const key = creds.login.trim().toLowerCase();
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
let generated = await this.generate(link, cookie, overallSignal);
@ -259,7 +263,7 @@ export class MegaWebFallback {
fileSize: null,
retriesUsed: 0
};
}, overallSignal);
}, key, overallSignal);
}
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
@ -276,7 +280,7 @@ export class MegaWebFallback {
this.sessions.clear();
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const QUEUE_WAIT_TIMEOUT_MS = 90000;
const guardedJob = async (): Promise<T> => {
@ -287,8 +291,9 @@ export class MegaWebFallback {
}
return job();
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
const prev = this.queues.get(key) ?? Promise.resolve();
const run = prev.then(guardedJob, guardedJob);
this.queues.set(key, run.then(() => undefined, () => undefined));
return raceWithAbort(run, signal);
}

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(),

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