From 6a0079f9d0ad22d99c0c4916c2591c25a73effaa Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 8 Mar 2026 20:30:33 +0100 Subject: [PATCH] Harden Debrid-Link cooldown and quota handling --- src/main/debrid.ts | 110 +++++++++++++++++++++++++------- src/main/download-manager.ts | 37 +++++++++++ src/renderer/App.tsx | 111 ++------------------------------- tests/debrid.test.ts | 90 ++++++++++++++++++++++++++ tests/download-manager.test.ts | 53 ++++++++++++++++ 5 files changed, 274 insertions(+), 127 deletions(-) diff --git a/src/main/debrid.ts b/src/main/debrid.ts index e398d45..da4b52f 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -38,12 +38,60 @@ const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([ const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]); /** Per-key cooldown cache: keyId → expiry timestamp. Parallel items skip keys that recently failed. */ const debridLinkKeyCooldowns = new Map(); +type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip"; +type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory }; +const debridLinkKeyCooldownDetails = new Map(); const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; // 2 min cooldown per failed key const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000; const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000; export function resetDebridLinkRuntimeStateForTests(): void { debridLinkKeyCooldowns.clear(); + debridLinkKeyCooldownDetails.clear(); +} + +export function primeDebridLinkRuntimeCooldownForTests(keyId: string, cooldownMs: number, message = "Debrid-Link Key im Cooldown"): void { + setDebridLinkKeyCooldownState(keyId, cooldownMs, message, "temporary"); +} + +function clearDebridLinkKeyCooldownState(keyId: string): void { + debridLinkKeyCooldowns.delete(keyId); + debridLinkKeyCooldownDetails.delete(keyId); +} + +function setDebridLinkKeyCooldownState( + keyId: string, + cooldownMs: number, + message: string, + category: DebridLinkCooldownCategory +): void { + if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) { + clearDebridLinkKeyCooldownState(keyId); + return; + } + debridLinkKeyCooldowns.set(keyId, Date.now() + Math.max(1000, Math.floor(cooldownMs))); + debridLinkKeyCooldownDetails.set(keyId, { message, category }); +} + +function getDebridLinkKeyCooldownState( + keyId: string, + now = Date.now() +): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null { + const until = Number(debridLinkKeyCooldowns.get(keyId) || 0); + if (!until) { + return null; + } + if (until <= now) { + clearDebridLinkKeyCooldownState(keyId); + return null; + } + const detail = debridLinkKeyCooldownDetails.get(keyId); + return { + until, + remainingMs: until - now, + message: detail?.message || "Debrid-Link Key im Cooldown", + category: detail?.category || "temporary" + }; } const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; @@ -488,19 +536,19 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin const hostEntry = findDebridLinkHostEntry(payload, hostLabel); if (!hostEntry) { if (endpoint.endsWith("/all")) { - return { - keyId: apiKey.id, - keyLabel: apiKey.label, - host: hostLabel, - fetchedAt: Date.now(), - trafficCurrentBytes: null, - trafficMaxBytes: null, - linksCurrent: null, - linksMax: null, - note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.` - }; + break; } - break; + return { + keyId: apiKey.id, + keyLabel: apiKey.label, + host: hostLabel, + fetchedAt: Date.now(), + trafficCurrentBytes: null, + trafficMaxBytes: null, + linksCurrent: null, + linksMax: null, + note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.` + }; } const daySize = asRecord(hostEntry.daySize); @@ -1567,6 +1615,8 @@ class DebridLinkClient { const failures: string[] = []; let usableKeySeen = false; + const cooldownFailures: string[] = []; + let earliestCooldownUntil = 0; // Always start from first key — use first available, skip disabled/limited/cooldown. // This ensures all parallel items use the same key until it's actually exhausted. @@ -1581,15 +1631,20 @@ class DebridLinkClient { logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`); continue; } - const keyCooldownExpiry = debridLinkKeyCooldowns.get(apiKey.id); - if (keyCooldownExpiry && Date.now() < keyCooldownExpiry) { - logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${new Date(keyCooldownExpiry).toLocaleTimeString()}), pruefe naechsten Key`); + const keyCooldownState = getDebridLinkKeyCooldownState(apiKey.id); + if (keyCooldownState) { + logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${new Date(keyCooldownState.until).toLocaleTimeString()}), pruefe naechsten Key`); + cooldownFailures.push(`Debrid-Link${keyLabel}: ${keyCooldownState.message}`); + if (!earliestCooldownUntil || keyCooldownState.until < earliestCooldownUntil) { + earliestCooldownUntil = keyCooldownState.until; + } continue; } usableKeySeen = true; try { const result = await this.unrestrictWithKey(apiKey, link, signal); + clearDebridLinkKeyCooldownState(apiKey.id); logger.info(`Debrid-Link${keyLabel}: Unrestrict OK -> ${result.fileName || "?"}`); return { ...result, @@ -1601,7 +1656,9 @@ class DebridLinkClient { const failure = await this.classifyKeyFailure(error, apiKey, link, signal); failures.push(`Debrid-Link${keyLabel}: ${failure.message}`); if (failure.cooldownMs > 0) { - debridLinkKeyCooldowns.set(apiKey.id, Date.now() + failure.cooldownMs); + setDebridLinkKeyCooldownState(apiKey.id, failure.cooldownMs, failure.message, failure.category || "temporary"); + } else { + clearDebridLinkKeyCooldownState(apiKey.id); } if (failure.fatal) { throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`); @@ -1702,6 +1759,10 @@ class DebridLinkClient { } if (!usableKeySeen) { + if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) { + const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000); + throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`); + } throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar"); } throw new Error(failures.join(" | ") || "Debrid-Link: Kein aktiver API-Key verfuegbar"); @@ -1893,7 +1954,7 @@ class DebridLinkClient { apiKey: ReturnType[number], link: string, signal?: AbortSignal - ): Promise<{ fatal: boolean; cooldownMs: number; message: string }> { + ): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory }> { const errorText = compactErrorText(error).replace(/^Error:\s*/i, ""); if (error instanceof DebridLinkApiError) { const code = String(error.code || "").trim() || `HTTP ${error.status}`; @@ -1903,14 +1964,16 @@ class DebridLinkClient { return { fatal: false, cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, - message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})` + message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})`, + category: "invalid" }; } if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) { return { fatal: false, cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS, - message: `API-Rate-Limit erreicht (${code}: ${description})` + message: `API-Rate-Limit erreicht (${code}: ${description})`, + category: "rate_limit" }; } if (DEBRID_LINK_QUOTA_ERRORS.has(code)) { @@ -1919,21 +1982,24 @@ class DebridLinkClient { return { fatal: false, cooldownMs, - message: `Quota erreicht fuer ${hoster} (${code}: ${description})` + message: `Quota erreicht fuer ${hoster} (${code}: ${description})`, + category: "quota" }; } if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) { return { fatal: false, cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, - message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})` + message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`, + category: "skip" }; } if (DEBRID_LINK_FATAL_LINK_ERRORS.has(code)) { return { fatal: true, cooldownMs: 0, - message: description + message: description, + category: "temporary" }; } if (DEBRID_LINK_RETRYABLE_ERRORS.has(code) || error.status >= 500) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 5e9399a..dd21143 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -435,6 +435,19 @@ function isUnrestrictFailure(errorText: string): boolean { || text.includes("login required") || text.includes("login failed"); } +function parseDebridLinkCooldownRetry(errorText: string): { delayMs: number; detail: string } | null { + const match = String(errorText || "").match(/debrid_link_cooldown:(\d+):(.*)$/i); + if (!match) { + return null; + } + const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, Number(match[1]) || 0)); + const detail = String(match[2] || "").trim(); + if (!delayMs) { + return null; + } + return { delayMs, detail }; +} + function isProviderBusyUnrestrictError(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("too many active") @@ -6788,6 +6801,30 @@ export class DownloadManager extends EventEmitter { } if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { + const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText); + if (debridLinkCooldown) { + active.unrestrictRetries += 1; + item.retries += 1; + const failureProvider = this.getProviderFailureKeyForItem(item); + this.recordProviderFailure(failureProvider); + this.applyProviderBusyBackoff(failureProvider, debridLinkCooldown.delayMs); + logger.warn( + `Debrid-Link-Cooldown: item=${item.fileName || item.id}, ` + + `retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${debridLinkCooldown.delayMs}ms, ` + + `detail=${debridLinkCooldown.detail || errorText}, link=${item.url.slice(0, 80)}` + ); + this.queueRetry( + item, + active, + debridLinkCooldown.delayMs, + `Debrid-Link Cooldown, Retry ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(debridLinkCooldown.delayMs / 1000)}s)` + ); + item.lastError = debridLinkCooldown.detail || errorText; + this.persistSoon(); + this.emitState(); + return; + } + active.unrestrictRetries += 1; item.retries += 1; const failureProvider = this.getProviderFailureKeyForItem(item); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index dcc6d28..7a846a2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1507,112 +1507,13 @@ export function App(): ReactElement { if (apiKeys.length === 0) { throw new Error("Debrid-Link ist nicht konfiguriert"); } - - let loadedAny = false; - let firstError = ""; - for (let index = 0; index < apiKeys.length; index += 1) { - if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { - return; - } - - const apiKey = apiKeys[index]; - const controller = new AbortController(); - const timer = window.setTimeout(() => controller.abort(), 8000); - let info: DebridLinkHostLimitInfo; - try { - const readLimitsPayload = async (path: "limits" | "limits/all") => { - const response = await fetch(`https://debrid-link.com/api/v2/downloader/${path}`, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey.token}` - }, - signal: controller.signal - }); - const payload = await response.json() as { - success?: boolean; - value?: { - hosters?: Array<{ - name?: string; - displayName?: string; - daySize?: { current?: number; value?: number }; - dayCount?: { current?: number; value?: number }; - }>; - }; - error?: string; - error_description?: string; - }; - if (!response.ok || !payload?.success) { - throw new Error(String(payload?.error_description || payload?.error || `HTTP ${response.status}`)); - } - return payload; - }; - - let payload = await readLimitsPayload("limits/all"); - let hostEntry = (payload.value?.hosters || []).find((entry) => String(entry.name || "").toLowerCase() === "rapidgator"); - if (!hostEntry) { - payload = await readLimitsPayload("limits"); - hostEntry = (payload.value?.hosters || []).find((entry) => String(entry.name || "").toLowerCase() === "rapidgator"); - } - if (!hostEntry) { - info = { - keyId: apiKey.id, - keyLabel: apiKey.label, - host: "rapidgator", - fetchedAt: Date.now(), - trafficCurrentBytes: null, - trafficMaxBytes: null, - linksCurrent: null, - linksMax: null, - note: "Rapidgator nicht in der API-Antwort gefunden." - }; - } else { - info = { - keyId: apiKey.id, - keyLabel: apiKey.label, - host: String(hostEntry.displayName || hostEntry.name || "rapidgator"), - fetchedAt: Date.now(), - trafficCurrentBytes: typeof hostEntry.daySize?.current === "number" ? hostEntry.daySize.current : null, - trafficMaxBytes: typeof hostEntry.daySize?.value === "number" ? hostEntry.daySize.value : null, - linksCurrent: typeof hostEntry.dayCount?.current === "number" ? hostEntry.dayCount.current : null, - linksMax: typeof hostEntry.dayCount?.value === "number" ? hostEntry.dayCount.value : null, - note: "" - }; - } - } catch (error) { - const message = String(error || "Quota konnte nicht geladen werden"); - if (!firstError) { - firstError = message; - } - info = { - keyId: apiKey.id, - keyLabel: apiKey.label, - host: "rapidgator", - fetchedAt: Date.now(), - trafficCurrentBytes: null, - trafficMaxBytes: null, - linksCurrent: null, - linksMax: null, - note: message - }; - } finally { - window.clearTimeout(timer); - } - - if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { - return; - } - - loadedAny = true; - setDebridLinkHostLimits((prev) => ({ - ...prev, - [info.keyId]: info - })); - - } - - if (!loadedAny && firstError) { - throw new Error(firstError); + const limits = await window.rd.getDebridLinkHostLimits(); + if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { + return; } + setDebridLinkHostLimits( + Object.fromEntries(limits.map((info) => [info.keyId, info])) + ); } catch (error) { if (!mountedRef.current || debridLinkHostLimitsRequestRef.current !== requestId) { return; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index a044c05..374b405 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -424,6 +424,41 @@ describe("debrid service", () => { expect(authHeaders).toEqual(["Bearer dl-key-one"]); }); + it("returns a cooldown marker when all Debrid-Link keys are temporarily cooling down", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let addCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (!url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response("not-found", { status: 404 }); + } + addCalls += 1; + return new Response(JSON.stringify({ + success: false, + error: "floodDetected", + error_description: "too many requests" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow("API-Rate-Limit erreicht"); + await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow(/debrid_link_cooldown:\d+:/i); + expect(addCalls).toBe(2); + }); + it("uses BestDebrid auth header without token query fallback", async () => { const settings = { ...defaultSettings(), @@ -659,6 +694,61 @@ describe("debrid service", () => { expect(info[0].linksMax).toBe(500); }); + it("falls back from Debrid-Link limits/all to limits when the host is only present in limits", async () => { + const calledUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "uploaded", + daySize: { current: 1, value: 2 }, + dayCount: { current: 3, value: 4 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/limits")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + displayName: "Rapidgator", + daySize: { current: 2147483648, value: 150323855360 }, + dayCount: { current: 42, value: 500 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info).toHaveLength(1); + expect(info[0].host).toBe("rapidgator"); + expect(info[0].trafficCurrentBytes).toBe(2147483648); + expect(info[0].trafficMaxBytes).toBe(150323855360); + expect(info[0].linksCurrent).toBe(42); + expect(info[0].linksMax).toBe(500); + expect(calledUrls.some((url) => url.includes("/limits/all"))).toBe(true); + expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true); + }); + it("uses AllDebrid web path when enabled", async () => { const settings = { ...defaultSettings(), diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 2da401e..c654b78 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -10,6 +10,7 @@ import { defaultSettings } from "../src/main/constants"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { createStoragePaths, emptySession } from "../src/main/storage"; +import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid"; const tempDirs: string[] = []; const originalFetch = globalThis.fetch; @@ -42,6 +43,7 @@ async function removeDirWithRetries(dir: string): Promise { afterEach(async () => { globalThis.fetch = originalFetch; + resetDebridLinkRuntimeStateForTests(); for (const dir of tempDirs.splice(0)) { await removeDirWithRetries(dir); } @@ -762,6 +764,57 @@ describe("download manager", () => { expect(fs.statSync(item.targetPath).size).toBe(binary.length); }); + it("queues Debrid-Link cooldown retries when wrapped unrestrict errors carry the cooldown marker", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + let fetchCalls = 0; + + globalThis.fetch = async (): Promise => { + fetchCalls += 1; + return new Response("not-found", { status: 404 }); + }; + + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 2, + autoExtract: false + }; + + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + for (const key of keys) { + primeDebridLinkRuntimeCooldownForTests(key.id, 60_000, `${key.label} im Cooldown`); + } + + const manager = new DownloadManager( + settings, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "debridlink-cooldown", links: ["https://rapidgator.net/file/example.part1.rar.html"] }]); + await manager.start(); + await waitFor(() => { + const item = Object.values(manager.getSnapshot().session.items)[0]; + return Boolean(item && item.status === "queued" && /debrid-link cooldown/i.test(item.fullStatus || "")); + }, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toContain("Debrid-Link Cooldown"); + expect(item?.lastError).toContain("im Cooldown"); + expect(item?.retries).toBe(1); + expect(fetchCalls).toBe(0); + + await manager.stop(); + }); + it("restarts from zero after repeated resume underflow on fresh direct links", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);