diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 3d76499..c9181f1 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -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(); 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(); +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: — 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 }; } diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index bb67546..b676839 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -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; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 35cd8ad..8355c82 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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)"; diff --git a/tasks/lessons.md b/tasks/lessons.md index f64fe6e..7d6daf9 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -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. diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 4942247..bbafcd4 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -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 = []; + 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(), diff --git a/tests/mega-web-fallback.test.ts b/tests/mega-web-fallback.test.ts index 12e28a2..771172e 100644 --- a/tests/mega-web-fallback.test.ts +++ b/tests/mega-web-fallback.test.ts @@ -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('
', { status: 200 }); + } + if (urlStr.includes("form=debrid")) { + // Keine processDebrid(...)-Codes — nur die Tageslimit-Meldung als Page-Error. + return new Response('
Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.
', { 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 }) => {