diff --git a/src/main/debrid.ts b/src/main/debrid.ts index f0ee502..76a28aa 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -283,6 +283,16 @@ const megaDebridAccountCooldowns = new Map(); const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; +// A Mega-Web account abort (the shared unrestrict timeout firing while this +// account ran) only cools the account down — so the next attempt rotates on — +// if it actually ran this long. Below this, it's treated as a quick user-cancel +// (no cooldown). Env-overridable for tests. +const MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT = 8000; +function getMegaDebridAbortMinRunMs(): number { + const fromEnv = Number(process.env.RD_MEGA_ABORT_MIN_RUN_MS ?? NaN); + return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT; +} + const megaDebridEmptyResponseStreaks = new Map(); export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3; @@ -1958,8 +1968,29 @@ class MegaDebridClient { sourceAccountLabel: account.label }; } catch (error) { - const failure = MegaDebridClient.classifyAccountFailure(error); const elapsedMs = Date.now() - testStartedAt; + const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + // Timeout/abort on THIS account (the shared unrestrict signal fired). Cool + // the account down — if it actually ran, not a quick user-cancel — so the + // download-manager's retry rotates to the NEXT account instead of hammering + // this one. The shared signal is now aborted, so we stop this pass; the + // retry runs the rotation fresh with this account skipped. A genuine cancel + // is not retried by the caller, so the cooldown is harmless there. + if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { + const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); + if (ranLongEnough) { + setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); + } + failures.push(`Mega-Debrid${accountLabel}: ${abortText}`); + logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { + elapsedMs, + reason: abortText, + cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, + next: "naechster Account beim Retry" + }); + throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); + } + const failure = MegaDebridClient.classifyAccountFailure(error); failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`); let parkUntilRestart = false; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 32d04a8..0f44695 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1150,6 +1150,10 @@ function rotationEventText(ev: { event: string; cooldownSec?: number; next?: str return `fehlgeschlagen${cd}${nx}`; } case "FATAL": return "abgebrochen (fataler Fehler)"; + case "TIMEOUT_COOLDOWN": { + const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : ""; + return `Timeout/Abbruch${cd} → nächster Account beim Retry`; + } 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)"; diff --git a/tasks/todo.md b/tasks/todo.md index 73b7fd9..f03db12 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,12 +1,61 @@ # Real-Debrid-Downloader — Tasks (Stand 2026-06-07) -**Status: nichts hängt, nichts ist halbfertig.** Alle zugesagten Tasks sind erledigt -(siehe Archiv unten). Was hier offen steht, ist freiwilliger Backlog. +**Status:** Alle zugesagten Features erledigt+released (Archiv unten). EIN Bug analysiert ++ geparkt (Mega-Web Account-3-Rotation, siehe direkt unten — wartet auf 1 Log-Zahl vom User). +Rest ist freiwilliger Backlog. --- ## 🟢 OFFEN — Backlog (optional, nie begonnen) +### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187) +**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief) +setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel, +RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account +und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure). +Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt +rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2 +rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1 +brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte +per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft. + +**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)** +**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen +Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert. + +**Verifizierter Mechanismus (Code):** +- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s → + weiter zu Account 2. Account 2 → `aborted:debrid`. +- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein → + `throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.** +- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim + download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein + Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2. +- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort, + timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis + 180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt + das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance. + +**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich" +(11:51:45–11:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 — +**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der +wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal → +Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig +bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt +nur sporadisch (no-server-Poll) bis ins 60s-Timeout. + +**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap. +debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann +beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink` +({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider +unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal, +AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop; +eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3). +**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST). +**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download). +Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation +nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten. + ### Features / UX (nach ROI) App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 0dc67e1..30a59bb 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -11,6 +11,7 @@ afterEach(() => { globalThis.fetch = originalFetch; resetDebridLinkRuntimeStateForTests(); resetMegaDebridRuntimeStateForTests(); + delete process.env.RD_MEGA_ABORT_MIN_RUN_MS; vi.restoreAllMocks(); }); @@ -1641,6 +1642,77 @@ describe("debrid service", () => { expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true); }, 20000); + it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout + 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; + + const loginsSeen: Array = []; + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + loginsSeen.push(account?.login); + if (account?.login === "user1") { + throw new Error("aborted:debrid"); + } + return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 }; + }); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const user1Key = `${getMegaDebridAccountId("user1")}:web`; + + // Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down. + await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow(); + expect(loginsSeen).toContain("user1"); + expect(loginsSeen).not.toContain("user2"); + expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull(); + + // Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served. + loginsSeen.length = 0; + const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2"); + expect(loginsSeen).not.toContain("user1"); + expect(loginsSeen).toContain("user2"); + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + }, 20000); + + it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown + 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; + + const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); }); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + const user1Key = `${getMegaDebridAccountId("user1")}:web`; + + await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow(); + expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull(); + }, 20000); + it("respects provider selection and does not append hidden providers", async () => { const settings = { ...defaultSettings(),