From 26df55f7ba21fa63b4e5e3910ad6a2343d42ddd6 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 21 Jun 2026 21:21:34 +0200 Subject: [PATCH] Fix: Mega-Debrid Einzel-Account friert bei 60s-Timeout nicht mehr die ganze Liste ein MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom (per Ferndiagnose live verifiziert): Bei nur EINEM Mega-Debrid-Account lief der Download eine Weile sauber, dann standen schlagartig ALLE Items ~120s im "Mega-Debrid Cooldown" — obwohl der Account voellig gesund war (andere Links loesten zeitgleich in 13-18s auf). Jede Cooldown-Zeile zeigte exakt dieselbe Deadline (20:52:55.901) -> ein einziger account-weiter Cooldown, einmal gesetzt. Ursache: Laeuft eine Web-Aufloesung laenger als das 60s-Gesamt-Timeout, feuert der Abbruch-Pfad (debrid.ts) einen 120s-Account-Cooldown. Dessen einziger Zweck (laut Code-Kommentar) ist, den Retry auf den NAECHSTEN Account rotieren zu lassen. Bei nur einem Account gibt es keinen naechsten -> stattdessen findet jedes folgende Item den einzigen Account im Cooldown und wird bis zu 120s geparkt -> die ganze Liste steht. Ein 60s-Timeout ist ein Signal fuer einen LANGSAMEN LINK, nicht fuer einen ungesunden Account. Fix: Der Account-Cooldown wird nur noch gesetzt, wenn es tatsaechlich einen anderen nutzbaren Account zum Rotieren gibt. Ohne Rotationsziel (Einzel-Account / alle anderen belegt) wird der Account NICHT mehr eingefroren; stattdessen wird nur der langsame Link selbst geparkt (mega_debrid_slow_link -> Item-Retry), waehrend alle anderen Items weiter ueber den gesunden Account laufen. Das Mehr-Account-Verhalten (Rotation per Account-Cooldown) bleibt unveraendert. Der baugleiche Debrid-Link-Pfad (Einzel-Key, debrid.ts) ist derselbe Muster-Typ, aber ein separater, hier nicht genutzter Provider mit eigener Nachbehandlung - bewusst nicht mitgebuendelt. Tests: debrid.test.ts (Einzel-Account-Abbruch parkt nur den Link, KEIN Account-Cooldown, zweites Item loest weiter auf) + unrestrict-retry.test.ts (parseMegaDebridSlowLinkRetry, keine Token-Kollision). Suite 934 gruen, tsc 6. --- src/main/debrid.ts | 42 ++++++++++++++++++++++------- src/main/download-manager.ts | 32 ++++++++++++++++++++++ tests/debrid.test.ts | 49 ++++++++++++++++++++++++++++++++++ tests/unrestrict-retry.test.ts | 27 ++++++++++++++++++- 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 6db35fa..1bc233a 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -288,6 +288,7 @@ type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporar type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean }; const megaDebridAccountCooldowns = new Map(); const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; +const MEGA_DEBRID_SLOW_LINK_RETRY_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 @@ -2068,15 +2069,33 @@ class MegaDebridClient { } catch (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. + // Timeout/abort on THIS account (the shared unrestrict timeout fired). The + // account-wide cooldown exists ONLY to make the retry rotate to another + // account — so it is set only when another usable account actually exists. + // With no rotation target (single account / all others busy), cooling the + // sole account would freeze EVERY queued item while the account is healthy; + // a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so + // we park just this link (mega_debrid_slow_link) and leave the account free + // for other items. A quick user-cancel (below the min run) parks nothing. if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); - if (ranLongEnough) { + const otherUsableAccounts = orderedEntries.reduce((count, candidate) => { + if (candidate.account.id === account.id) { + return count; + } + if (isMegaDebridAccountDisabled(settings, candidate.account.id)) { + return count; + } + if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) { + return count; + } + if (getMegaDebridAccountCooldownState(`${candidate.account.id}:${mode}`)) { + return count; + } + return count + 1; + }, 0); + const rotateToAnotherAccount = ranLongEnough && otherUsableAccounts > 0; + if (rotateToAnotherAccount) { setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); } traceConversionPhase({ @@ -2085,15 +2104,18 @@ class MegaDebridClient { account: rotationLabel, workMs: elapsedMs, outcome: "aborted", - detail: `${abortText}${ranLongEnough ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ""}` + detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}` }); 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" + cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, + next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)" }); + if (ranLongEnough && !rotateToAnotherAccount) { + throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`); + } throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); } const failure = MegaDebridClient.classifyAccountFailure(error); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index f38a8a9..ae14cd7 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -660,6 +660,20 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() }; } +export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null { + const text = String(errorText || ""); + const match = text.match(/mega_debrid_slow_link:(\d+)/i); + if (!match) { + return null; + } + const raw = Number(match[1]); + if (!Number.isFinite(raw) || raw <= 0) { + return null; + } + const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, raw)); + return { delayMs, detail: text.replace(/mega_debrid_slow_link:\d+:/i, "").trim() }; +} + export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null { const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is); if (!match) { @@ -9467,6 +9481,24 @@ export class DownloadManager extends EventEmitter { } const megaRawError = error instanceof Error ? String(error.message || "") : String(error || ""); + const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError); + if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + item.provider = null; + logger.warn(`Mega-Debrid Link langsam (Timeout): item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaSlowLinkRetry.delayMs}ms, link=${item.url.slice(0, 80)}`); + this.queueRetry( + item, + active, + megaSlowLinkRetry.delayMs, + `Mega-Debrid: Link zu langsam, Einzel-Retry in ${Math.ceil(megaSlowLinkRetry.delayMs / 1000)}s` + ); + item.lastError = megaSlowLinkRetry.detail || errorText; + this.persistSoon(); + this.emitState(); + return; + } + const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError); if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) { active.unrestrictRetries += 1; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index cc38582..042b00d 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -2071,6 +2071,55 @@ describe("debrid service", () => { expect(calls).toBeGreaterThanOrEqual(1); }, 20000); + it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user", + megaPassword: "pass", + megaCredentials: "user:pass", + 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 controller = new AbortController(); + let calls = 0; + const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { + calls += 1; + if (calls === 1) { + controller.abort("simulated-60s-timeout"); + return Promise.reject(new Error("aborted")); + } + return Promise.resolve({ + fileName: "healthy.rar", + directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar", + fileSize: null, + retriesUsed: 0 + }); + }); + + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + + const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e); + expect(err).toBeTruthy(); + expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i); + + const key = `${getMegaDebridAccountId("user")}:web`; + expect(getMegaDebridAccountCooldownState(key)).toBeNull(); + + const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html"); + expect(second.provider).toBe("megadebrid"); + expect(calls).toBeGreaterThanOrEqual(2); + }, 20000); + it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => { const key = `${getMegaDebridAccountId("user1")}:web`; expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3); diff --git a/tests/unrestrict-retry.test.ts b/tests/unrestrict-retry.test.ts index a035a39..6229f3e 100644 --- a/tests/unrestrict-retry.test.ts +++ b/tests/unrestrict-retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark } from "../src/main/download-manager"; +import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager"; describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => { it("starts fast (<= 3s) instead of the 5s..120s exponential", () => { @@ -63,6 +63,31 @@ describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay }); }); +describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => { + it("parses the encoded delay from a slow-link error", () => { + const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted"); + expect(r).not.toBeNull(); + expect(r!.delayMs).toBe(120000); + expect(r!.detail).toContain("Mega-Debrid"); + }); + + it("parses it when embedded in the aggregated provider-chain error", () => { + const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)"; + expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000); + }); + + it("clamps to [1s, 15min]", () => { + expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000); + expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000); + }); + + it("does not collide with the account-cooldown or reset-park tokens", () => { + expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull(); + expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull(); + expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull(); + }); +}); + describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => { it("parses the encoded until-reset delay from the park token", () => { const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");