diff --git a/src/main/debrid.ts b/src/main/debrid.ts index a226fb0..dd22fe5 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -10,13 +10,6 @@ import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; const API_TIMEOUT_MS = 30000; -/** Per-account/key attempt timeout for the rotation loops. Bounds a SINGLE - * account's unrestrict so a hanging account no longer eats the whole shared - * unrestrict budget (the download-manager wraps the entire rotation in one - * ~60s signal). Without this, account 1 hanging for 60s meant accounts 2/3 - * were never tried. With it, a hang fails this account (temporary cooldown) - * and the loop moves on to the next. */ -const PER_ACCOUNT_ATTEMPT_TIMEOUT_MS = Math.max(8000, Math.min(45000, Number(process.env.RD_PER_ACCOUNT_TIMEOUT_MS) || 25000)); const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; @@ -1984,17 +1977,9 @@ class MegaDebridClient { const testStartedAt = Date.now(); usableAccountSeen = true; - // Per-account timeout: a hang on THIS account must not consume the whole - // shared unrestrict budget — otherwise the loop never reaches the next. - const attemptController = new AbortController(); - const attemptTimer = setTimeout(() => attemptController.abort(), PER_ACCOUNT_ATTEMPT_TIMEOUT_MS); - const attemptSignal = signal - ? AbortSignal.any([signal, attemptController.signal]) - : attemptController.signal; try { const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); - const result = await client.unrestrictLink(link, attemptSignal); - clearTimeout(attemptTimer); + const result = await client.unrestrictLink(link, signal); clearMegaDebridAccountCooldownState(cooldownKey); const elapsedMs = Date.now() - testStartedAt; logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`); @@ -2010,19 +1995,7 @@ class MegaDebridClient { sourceAccountLabel: account.label }; } catch (error) { - clearTimeout(attemptTimer); - // If the GLOBAL signal aborted (user stop / overall unrestrict budget), - // stop the whole rotation — don't keep hammering the next account. - if (signal?.aborted) { - throw error; - } - // If only THIS account's own timeout fired, treat it as a temporary - // failure (short cooldown) and move on to the next account — instead of - // letting it be misclassified as a fatal abort. - const perAccountTimedOut = attemptController.signal.aborted; - const failure = perAccountTimedOut - ? { fatal: false, cooldownMs: 30000, message: `Account-Timeout nach ${Math.ceil(PER_ACCOUNT_ATTEMPT_TIMEOUT_MS / 1000)}s`, category: "temporary" as MegaDebridCooldownCategory } - : MegaDebridClient.classifyAccountFailure(error); + const failure = MegaDebridClient.classifyAccountFailure(error); const elapsedMs = Date.now() - testStartedAt; failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`); if (failure.cooldownMs > 0) { @@ -2667,16 +2640,8 @@ class DebridLinkClient { const testStartedAt = Date.now(); usableKeySeen = true; - // Per-key timeout: a hang on THIS key must not consume the whole shared - // unrestrict budget — otherwise the loop never reaches the next key. - const attemptController = new AbortController(); - const attemptTimer = setTimeout(() => attemptController.abort(), PER_ACCOUNT_ATTEMPT_TIMEOUT_MS); - const attemptSignal = signal - ? AbortSignal.any([signal, attemptController.signal]) - : attemptController.signal; try { - const result = await this.unrestrictWithKey(apiKey, link, attemptSignal); - clearTimeout(attemptTimer); + const result = await this.unrestrictWithKey(apiKey, link, signal); clearDebridLinkKeyCooldownState(apiKey.id); setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich"); const elapsedMs = Date.now() - testStartedAt; @@ -2693,16 +2658,7 @@ class DebridLinkClient { sourceAccountLabel: apiKey.label }; } catch (error) { - clearTimeout(attemptTimer); - // Global abort (user stop / overall budget) → stop the whole rotation. - if (signal?.aborted) { - throw error; - } - // Only THIS key's own timeout fired → temporary failure, try the next key. - const perKeyTimedOut = attemptController.signal.aborted; - const failure = perKeyTimedOut - ? { fatal: false, cooldownMs: 30000, message: `Key-Timeout nach ${Math.ceil(PER_ACCOUNT_ATTEMPT_TIMEOUT_MS / 1000)}s`, category: "temporary" as DebridLinkCooldownCategory } - : await this.classifyKeyFailure(error, apiKey, link, signal); + const failure = await this.classifyKeyFailure(error, apiKey, link, signal); const elapsedMs = Date.now() - testStartedAt; attemptedKeyFailures.push({ message: `Debrid-Link${keyLabel}: ${failure.message}`, diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index c21bce0..3a4b359 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1386,76 +1386,12 @@ describe("debrid service", () => { try { await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i); expect(megaWeb).toHaveBeenCalledTimes(1); - // Der Rotations-Loop (unrestrictWithAccounts) wickelt das Caller-Signal jetzt - // mit einem Per-Account-Timeout (AbortSignal.any([signal, attemptController.signal])). - // Die an den Web-Unrestrict gereichte Signal-INSTANZ ist daher absichtlich NICHT - // mehr identisch mit controller.signal — entscheidend ist das VERHALTEN: das - // Caller-Cancel propagiert weiterhin durch (das gereichte Signal ist aborted), - // worauf der Web-Unrestrict abbricht. (Bitte nicht zurueck auf .toBe aendern.) - const passedSignal = megaWeb.mock.calls[0]?.[1]; - expect(passedSignal).toBeInstanceOf(AbortSignal); - expect(passedSignal?.aborted).toBe(true); + expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal); } finally { clearTimeout(abortTimer); } }); - it("rotation per-account timeout: a hanging account is skipped so the next account is tried", async () => { - // Kern des v1.7.168-Fix: haengt Account 1, darf das NICHT die ganze Rotation - // fressen — der Per-Account-Timeout (PER_ACCOUNT_ATTEMPT_TIMEOUT_MS, default 25s) - // bricht NUR acc1 ab (kurzer Cooldown), der Loop probiert acc2. Ohne globales - // Signal (kein download-manager-Wrap) testet das den Per-Account-Timeout isoliert. - const settings = { - ...defaultSettings(), - token: "", - bestToken: "", - allDebridToken: "", - megaLogin: "user1", - megaPassword: "pass1", - megaCredentials: "user1:pass1\nuser2:pass2", - providerOrder: [] as const, - providerPrimary: "megadebrid" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: false - }; - - // API connect schlaegt pro Account schnell fehl (500) -> Web-Fallback (megaWeb). - globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; - - let call = 0; - const megaWeb = vi.fn((_link: string, signal?: AbortSignal) => { - call += 1; - if (call === 1) { - // Account 1 haengt, bis sein eigener Per-Account-Timeout das Signal abortet. - return new Promise((_, reject) => { - if (signal?.aborted) { reject(new Error("aborted:acc1-hang")); return; } - signal?.addEventListener("abort", () => reject(new Error("aborted:acc1-hang")), { once: true }); - }); - } - // Account 2 liefert sofort. - return Promise.resolve({ - fileName: "acc2.rar", - directUrl: "https://mega-web.example/acc2.rar", - fileSize: null, - retriesUsed: 0 - }); - }); - - const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); - vi.useFakeTimers(); - try { - const pending = service.unrestrictLink("https://rapidgator.net/file/rotate-acc"); - // Per-Account-Timeout von acc1 (25s) feuern lassen -> acc1 faellt -> Loop -> acc2. - await vi.advanceTimersByTimeAsync(30000); - const result = await pending; - expect(result.directUrl).toBe("https://mega-web.example/acc2.rar"); - expect(megaWeb).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - it("respects provider selection and does not append hidden providers", async () => { const settings = { ...defaultSettings(),