Mega-Debrid: Account am Tageslimit bis Neustart parken (Streak-Heuristik) statt endlos neu testen

User-Entscheidung: ein Mega-Debrid-Account am Tageslimit soll bis zum Programm-Neustart
uebersprungen werden, nicht alle 20s/2min neu getestet.

Ground Truth (Support-Bundle gegrept): der limitierte Account liefert im Web-Pfad NIE eine
unterscheidbare Meldung — "Kein Server" = 0 Treffer, "Antwort leer" = 20.861. Tageslimit und
transienter Blip sind auf Message-Ebene nicht trennbar (generate() findet ohne processDebrid-
Code keinen Code -> return null -> "Antwort leer"). Ein Trigger auf "Kein Server" waere toter Code.

Loesung (Verhaltens-Signal statt Wortlaut):
- megaDebridEmptyResponseStreaks zaehlt aufeinanderfolgende "Antwort leer"/"Kein Server"-
  Treffer je Account; ab 3 wird der Account bis Neustart geparkt (until=MAX_SAFE_INTEGER,
  nur In-Memory -> Neustart loescht). Erfolg/anderer Fehler setzt zurueck.
- classifyAccountFailure markiert beide Signale als limitSignal (Symmetrie: ein einzelner
  evtl. transienter Treffer parkt NICHT, behaelt kurzen Cooldown).
- Skip-Branch: "uebersprungen (bis Neustart gesperrt)", traegt nicht zu earliestCooldownUntil
  bei (kein absurder Retry-Timer); Post-Loop wirft klare Endmeldung wenn alle geparkt.
- generate() surfacet "Kein Server" zusaetzlich als Page-Error (falls es doch im HTML steht).
- UI: Rotations-Verlauf zeigt "bis Neustart gesperrt".

Verifiziert: tsc 9 (Baseline), 655 Tests + 5 neue (inkl. Wiring-E2E der eine echte leere
Antwort durch unrestrictWithAccounts->classify->catch->Park treibt), Build gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-05-31 21:08:43 +02:00
parent 254fce8736
commit ffcd0817cf
6 changed files with 339 additions and 28 deletions

View File

@ -313,15 +313,44 @@ function getDebridLinkKeyHostCooldownState(
};
}
/** Per-account cooldown cache for Mega-Debrid: accountId → expiry timestamp. */
/** Per-account cooldown cache for Mega-Debrid: accountId expiry timestamp.
* untilRestart: ein Tageslimit-Account wird fuer den REST der Laufzeit uebersprungen
* (nicht alle 20s/2min neu getestet) und kommt erst nach einem Neustart zurueck die
* Map liegt nur im RAM, ein Neustart loescht sie also automatisch. */
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory };
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; // 2 min cooldown per failed account
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
/** Zaehlt aufeinanderfolgende "Antwort leer"-Fehlversuche je Account-Key. Ein
* tageslimitierter Mega-Debrid-Account liefert im Web-Pfad KEINE unterscheidbare
* Meldung ("Kein Server" taucht in echten Logs nie auf immer nur "Antwort leer"),
* ist aber daran erkennbar, dass er PERSISTENT leer antwortet. Nach
* MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART aufeinanderfolgenden Leer-Antworten wird der
* Account bis Neustart geparkt; ein einzelner transienter Blip (Streak < Schwelle)
* behaelt den kurzen 20s-Cooldown. Ein Erfolg oder ein anderer Fehlertyp setzt den
* Zaehler zurueck. */
const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
/** Verbucht eine "Antwort leer"-Antwort fuer den Account-Key und liefert die neue
* Streak-Laenge. Ab MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART parkt der Aufrufer den
* Account bis Neustart. Exportiert fuer deterministische Tests. */
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
megaDebridEmptyResponseStreaks.set(accountId, streak);
return streak;
}
/** Setzt die "Antwort leer"-Streak zurueck (bei Erfolg oder einem anderen Fehlertyp). */
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId);
}
export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear();
megaDebridEmptyResponseStreaks.clear();
}
/** Periodic cleanup of expired Mega-Debrid cooldown entries. */
@ -341,6 +370,11 @@ export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldo
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
}
/** Parkt einen Account-Key bis Neustart (Tageslimit). Exportiert fuer Tests. */
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
}
function clearMegaDebridAccountCooldownState(accountId: string): void {
megaDebridAccountCooldowns.delete(accountId);
}
@ -349,8 +383,21 @@ function setMegaDebridAccountCooldownState(
accountId: string,
cooldownMs: number,
message: string,
category: MegaDebridCooldownCategory
category: MegaDebridCooldownCategory,
untilRestart = false
): void {
if (untilRestart) {
// Bis-Neustart-Sperre: never expires in-process (Number.MAX_SAFE_INTEGER liegt
// ausserhalb des gueltigen Date-Bereichs → Anzeige wird via untilRestart-Flag
// gesondert behandelt, nicht ueber new Date(until)).
megaDebridAccountCooldowns.set(accountId, {
until: Number.MAX_SAFE_INTEGER,
message,
category,
untilRestart: true
});
return;
}
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
clearMegaDebridAccountCooldownState(accountId);
return;
@ -365,7 +412,7 @@ function setMegaDebridAccountCooldownState(
export function getMegaDebridAccountCooldownState(
accountId: string,
now = Date.now()
): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory } | null {
): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory; untilRestart: boolean } | null {
const detail = megaDebridAccountCooldowns.get(accountId);
if (!detail) {
return null;
@ -378,7 +425,8 @@ export function getMegaDebridAccountCooldownState(
until: detail.until,
remainingMs: detail.until - now,
message: detail.message,
category: detail.category
category: detail.category,
untilRestart: detail.untilRestart === true
};
}
@ -1933,6 +1981,7 @@ class MegaDebridClient {
let usableAccountSeen = false;
const cooldownFailures: string[] = [];
let earliestCooldownUntil = 0;
let parkedUntilRestartSeen = false;
const totalAccounts = accounts.length;
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
const linkShort = String(link || "").slice(0, 80);
@ -1959,15 +2008,25 @@ class MegaDebridClient {
const cooldownKey = `${account.id}:${mode}`;
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
if (accountCooldownState) {
const untilStr = new Date(accountCooldownState.until).toLocaleTimeString();
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (Cooldown bis ${untilStr}), pruefe naechsten Account`);
const untilStr = accountCooldownState.untilRestart
? "Neustart"
: new Date(accountCooldownState.until).toLocaleTimeString();
const reasonText = accountCooldownState.untilRestart
? "Tageslimit erreicht — bis Neustart gesperrt"
: `Cooldown bis ${untilStr}`;
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (${reasonText}), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
reason: accountCooldownState.message,
category: accountCooldownState.category,
until: untilStr
});
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
// Eine Bis-Neustart-Sperre traegt NICHT zu earliestCooldownUntil bei: es gibt
// keinen sinnvollen endlichen Retry-Zeitpunkt (der Account kommt erst nach
// Neustart zurueck). Sonst wuerde MAX_SAFE_INTEGER einen absurden Retry-Timer setzen.
if (accountCooldownState.untilRestart) {
parkedUntilRestartSeen = true;
} else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = accountCooldownState.until;
}
continue;
@ -1985,6 +2044,7 @@ class MegaDebridClient {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal);
clearMegaDebridAccountCooldownState(cooldownKey);
clearMegaDebridEmptyResponseStreak(cooldownKey);
const elapsedMs = Date.now() - testStartedAt;
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
@ -2002,7 +2062,27 @@ class MegaDebridClient {
const failure = MegaDebridClient.classifyAccountFailure(error);
const elapsedMs = Date.now() - testStartedAt;
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) {
// "Antwort leer"-Streak fuehren: ein tageslimitierter Account antwortet
// PERSISTENT leer (siehe Kommentar an megaDebridEmptyResponseStreaks). Erreicht
// der Account die Schwelle, wird er bis Neustart geparkt — statt alle 20s neu
// getestet zu werden. failure.untilRestart deckt zusaetzlich den Fall ab, dass
// generate() die "Kein Server"-Meldung doch mal direkt liefert.
let parkUntilRestart = false;
let parkMessage = failure.message;
if (failure.limitSignal) {
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
parkUntilRestart = true;
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis Neustart gesperrt`;
}
} else {
clearMegaDebridEmptyResponseStreak(cooldownKey);
}
if (parkUntilRestart) {
setMegaDebridAccountCooldownState(cooldownKey, 0, parkMessage, "quota", true);
} else if (failure.cooldownMs > 0) {
setMegaDebridAccountCooldownState(cooldownKey, failure.cooldownMs, failure.message, failure.category);
} else {
clearMegaDebridAccountCooldownState(cooldownKey);
@ -2016,9 +2096,11 @@ class MegaDebridClient {
});
throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
}
const cooldownInfo = failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
const cooldownInfo = parkUntilRestart
? ", bis Neustart gesperrt"
: failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
// Find the next account that will be tried (for clearer log)
let nextLabel = "ENDE";
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
@ -2028,12 +2110,12 @@ class MegaDebridClient {
break;
}
}
logger.warn(`Mega-Debrid${accountLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Account (${nextLabel})`);
logger.warn(`Mega-Debrid${accountLabel}: ${parkUntilRestart ? parkMessage : failure.message}${cooldownInfo}, pruefe naechsten Account (${nextLabel})`);
logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
reason: parkUntilRestart ? parkMessage : failure.message,
elapsedMs,
reason: failure.message,
category: failure.category,
cooldownSec: failure.cooldownMs > 0 ? Math.ceil(failure.cooldownMs / 1000) : 0,
category: parkUntilRestart ? "quota" : failure.category,
cooldownSec: parkUntilRestart || failure.cooldownMs <= 0 ? 0 : Math.ceil(failure.cooldownMs / 1000),
next: nextLabel,
link: linkShort
});
@ -2045,6 +2127,12 @@ class MegaDebridClient {
const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000);
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
}
if (parkedUntilRestartSeen) {
// Alle (verbliebenen) Accounts haben ihr Tageslimit erreicht und sind bis
// Neustart gesperrt. KEIN mega_debrid_cooldown:<ms> — es gibt keinen sinnvollen
// Retry-Zeitpunkt; ein endlicher Timer wuerde nur erneut leer pollen.
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
}
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
}
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
@ -2056,7 +2144,7 @@ class MegaDebridClient {
*/
private static classifyAccountFailure(
error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory } {
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Abort — don't retry other accounts
@ -2089,14 +2177,18 @@ class MegaDebridClient {
};
}
// "Kein Server für diesen Hoster verfügbar" = Account-Tageslimit für diesen
// Hoster erschöpft (oder Hoster kurz nicht bedient). Quota-Cooldown, nächster Account.
// "Kein Server für diesen Hoster verfügbar" = Account-Tageslimit erschöpft (oder der
// Hoster ist kurz nicht bedient — laut Kommentar an MEGA_DEBRID_NO_SERVER_RE moeglich).
// Wie "Antwort leer" ein Limit-Signal: feedet die Streak (limitSignal). Erst nach
// mehreren Treffern wird der Account bis Neustart geparkt — ein einzelner (evtl.
// transienter) Treffer erzwingt KEINEN Neustart, behaelt aber den 2-Min-Cooldown.
if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: "Kein Server fuer diesen Hoster (Tageslimit/Hoster nicht verfuegbar)",
category: "quota"
category: "quota",
limitSignal: true
};
}
@ -2110,16 +2202,19 @@ class MegaDebridClient {
};
}
// Mega-Web "Antwort leer" / empty body — server frequently returns transient
// empty responses that recover within seconds. A 2-minute cooldown for this
// is way too long because the account is fundamentally healthy. Use a short
// 20s cooldown so the next download attempt can use this account again.
// Mega-Web "Antwort leer" / empty body — kann zweierlei sein: (a) ein transienter
// Blip (erholt sich in Sekunden → kurzer 20s-Cooldown reicht) ODER (b) ein
// tageslimitierter Account, der PERSISTENT leer antwortet. Da beide Faelle auf
// Message-Ebene identisch aussehen (in echten Logs taucht "Kein Server" nie auf,
// immer nur "Antwort leer"), markieren wir emptyResponse=true; der Aufrufer zaehlt
// die Streak und parkt den Account erst nach mehreren Leer-Antworten bis Neustart.
if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) {
return {
fatal: false,
cooldownMs: 20_000,
message: errorText || "Mega-Web transient empty response",
category: "temporary"
category: "temporary",
limitSignal: true
};
}

View File

@ -375,6 +375,15 @@ export class MegaWebFallback {
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
}
// Tageslimit dieses Accounts: die DEBRID-Seite enthaelt dann KEINEN processDebrid-
// Code (parseCodes leer → wir wuerden gleich null/"Antwort leer" liefern). Steht die
// "Kein Server für diesen Hoster"-Meldung als Page-Error im HTML, surface sie hier,
// damit die Rotation den Account als tageslimitiert erkennt statt als Leer-Blip.
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
if (noServerError) {
throw new Error(`Mega-Web: ${noServerError}`);
}
const code = pickCode(parseCodes(html), link);
if (!code) {
return null;

View File

@ -1143,16 +1143,21 @@ function formatCheckedAgo(checkedAt: number): string {
return `vor ${days} Tag${days === 1 ? "" : "en"}`;
}
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string }): string {
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string {
const untilRestart = /bis Neustart gesperrt/i.test(ev.reason || "");
switch (ev.event) {
case "OK": return "erfolgreich";
case "FAILED": {
if (untilRestart) {
const nx = ev.next && ev.next !== "ENDE" ? `${ev.next}` : "";
return `Tageslimit erreicht, bis Neustart gesperrt${nx}`;
}
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
const nx = ev.next && ev.next !== "ENDE" ? `${ev.next}` : "";
return `fehlgeschlagen${cd}${nx}`;
}
case "FATAL": return "abgebrochen (fataler Fehler)";
case "SKIP_COOLDOWN": return "übersprungen (Cooldown aktiv)";
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)";
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";

View File

@ -112,3 +112,26 @@ Ein Test, der eine Hilfsfunktion mit dem richtigen Flag direkt aufruft, beweist
das Flag funktioniert — NICHT, dass der Produktionspfad das Flag setzt. Für echte
Absicherung einen End-to-End-Test durch den realen Einstiegspunkt fahren und per
Negativ-Gate (Flag temporär entfernen → Test muss fallen) verifizieren.
## 2026-05-31 — Log-Symptom ≠ User-Wortlaut: greppen, bevor man auf eine Meldung triggert
**Muster:** User meldete Mega-Debrid-Tageslimit als „Kein Server für diesen Hoster". Ich
wollte den Fix an genau diese Meldung (`MEGA_DEBRID_NO_SERVER_RE`) hängen. Der Advisor
stoppte: der Screenshot zeigte als Cooldown-Grund **„Antwort leer"**, nicht „Kein Server".
**Beweis (Support-Bundle gegrept):** „Kein Server"/„Erreur"/„aucun serveur" = **0** Treffer
im ganzen Bundle, „Antwort leer" = **20.861** Treffer. Der limitierte Account liefert im
Web-Pfad NIE eine unterscheidbare Meldung — `generate()` findet ohne `processDebrid`-Code
keinen Code → `return null` → der Aufrufer macht daraus „Antwort leer". Ein Trigger auf
„Kein Server" wäre toter Code gewesen (= die v1.7.172-Falle, zum 2. Mal fast getreten).
**Regel:** Bevor man einen Fix an einen bestimmten Meldungstext hängt, in den ECHTEN Logs
greppen, ob dieser Text dort überhaupt vorkommt (`count`-Mode, alt-Text vs. Ist-Text). Sind
zwei Fälle auf Message-Ebene nicht unterscheidbar (Tageslimit vs. transienter Blip → beide
„Antwort leer"), nicht raten — über ein **Verhaltens-Signal** klassifizieren: hier eine
Streak (3× hintereinander leer → geparkt), nicht der einmalige Wortlaut.
**Wiring-Test nicht vergessen** (eigene Lesson): die Helfer-Unit-Tests beweisen nur den
Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt
(`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt
unbewiesen, dass der Produktionspfad das Signal überhaupt setzt.

View File

@ -3,7 +3,7 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
import { clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch;
@ -1566,6 +1566,152 @@ describe("debrid service", () => {
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
}, 20000);
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
// User-Entscheidung: ein tageslimitierter Account soll NICHT alle 20s neu getestet
// werden, sondern bis Programm-Neustart geparkt. Da Tageslimit und transienter
// Leer-Blip auf Message-Ebene identisch sind ("Antwort leer", nie "Kein Server" in
// echten Logs), zaehlt eine Streak: erst ab der Schwelle wird geparkt.
const key = `${getMegaDebridAccountId("user1")}:web`;
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); // 1. Blip -> NICHT parken
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2); // 2. -> NICHT parken
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3); // 3. -> Schwelle erreicht -> parken
// Ein Erfolg/anderer Fehlertyp setzt die Streak zurueck (Account wieder frisch).
clearMegaDebridEmptyResponseStreak(key);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
});
it("keeps an 'until restart' park active forever (never expires until process restart)", () => {
// Anders als ein zeitbasierter Cooldown darf die Bis-Neustart-Sperre NIE ablaufen
// (nur ein Neustart loescht die In-Memory-Map). Sonst wuerde der limitierte Account
// doch wieder getestet werden.
const key = `${getMegaDebridAccountId("user1")}:api`;
primeMegaDebridUntilRestartForTests(key);
const now = getMegaDebridAccountCooldownState(key);
expect(now?.untilRestart).toBe(true);
// Selbst 100 Tage in der Zukunft ist die Sperre noch aktiv.
const farFuture = Date.now() + 100 * 24 * 60 * 60 * 1000;
expect(getMegaDebridAccountCooldownState(key, farFuture)?.untilRestart).toBe(true);
});
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
// Beweis der Skip-Logik: ein bis Neustart geparkter Account wird NICHT mehr per
// Netzwerk getestet (kein megaWeb-Call fuer ihn), die Rotation nutzt den naechsten.
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;
// user1 ist bereits bis Neustart geparkt (Tageslimit). Beide Mode-Keys parken, damit
// der Test unabhaengig vom intern gewaehlten mode ("api"/"web") ist.
const user1 = getMegaDebridAccountId("user1");
primeMegaDebridUntilRestartForTests(`${user1}:api`);
primeMegaDebridUntilRestartForTests(`${user1}:web`);
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test");
// user1 wurde NICHT angefasst (geparkt), nur user2 wurde getestet und loest auf.
expect(loginsSeen).not.toContain("user1");
expect(loginsSeen).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => {
// Sind alle Accounts am Tageslimit (bis Neustart gesperrt), gibt es keinen
// sinnvollen endlichen Retry-Zeitpunkt: die Rotation muss klar und endgueltig
// scheitern statt einen absurden (MAX_SAFE_INTEGER) Retry-Timer zu werfen — und
// KEINEN Account erneut per Netzwerk pollen.
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;
for (const login of ["user1", "user2"]) {
const id = getMegaDebridAccountId(login);
primeMegaDebridUntilRestartForTests(`${id}:api`);
primeMegaDebridUntilRestartForTests(`${id}:web`);
}
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis Neustart gesperrt/i);
// Kein Account wurde erneut getestet.
expect(megaWeb).not.toHaveBeenCalled();
}, 20000);
it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => {
// Lesson "Wiring-Lock vs. Mechanism-Test": die Helfer-Unit-Tests beweisen nur, dass der
// Streak-Zaehler funktioniert — NICHT, dass der Produktionspfad ihn fuettert. Dieser Test
// faehrt eine ECHTE leere Antwort durch unrestrictWithAccounts -> classifyAccountFailure
// (limitSignal) -> catch -> recordStreak -> Park. Kaeme limitSignal nicht an, wuerde der
// catch-else die Streak loeschen und KEIN until-restart setzen -> Assertion faellt.
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1", // genau EIN Account
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;
// Provider "megadebrid" + preferApi:false -> resolveMegaDebridProvider -> "megadebrid-web"
// -> mode "web" -> Key-Suffix ":web" (das ist genau der Web-Pfad aus dem User-Screenshot).
const key = `${getMegaDebridAccountId("user1")}:web`;
// Streak schon EINS unter der Schwelle (2 vorherige leere Antworten) — noch NICHT geparkt.
recordMegaDebridEmptyResponseStreak(key);
recordMegaDebridEmptyResponseStreak(key);
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
// megaWeb liefert null -> der echte Web-Pfad macht daraus "Mega-Web Antwort leer".
const megaWeb = vi.fn(async () => null);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined);
// Der echte Fehlversuch tippte die Streak auf die Schwelle -> Park bis Neustart.
expect(megaWeb).toHaveBeenCalled(); // Account wurde wirklich getestet (nicht vorab geparkt)
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
}, 20000);
it("respects provider selection and does not append hidden providers", async () => {
const settings = {
...defaultSettings(),

View File

@ -89,6 +89,39 @@ describe("mega-web-fallback", () => {
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 () => {
// Tageslimit dieses Accounts: die DEBRID-Seite enthält KEINEN processDebrid-Code,
// sondern die Limit-Meldung als Page-Error. Früher -> kein Code -> null -> "Antwort
// leer" (auf Message-Ebene nicht als Tageslimit erkennbar). Jetzt muss die Meldung
// als Fehler hochkommen, damit die Rotation den Account als limitiert behandelt.
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")) {
// Keine processDebrid(...)-Codes — nur die Tageslimit-Meldung als Page-Error.
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);
// Ohne Code wird gar nicht erst gepollt — die Meldung kommt direkt von der Seite.
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 }) => {