From c5dd6f4f30f000f52ebcf4a75f1746c85130fb11 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 25 Mar 2026 19:53:54 +0100 Subject: [PATCH] Harden Debrid-Link key failover and pending-state handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add polling loop (5x 2s) in resolveDownloaderEntry when /add returns no downloadUrl — Debrid-Link sometimes needs seconds to generate links - Classify missing/expired downloadUrl as temporary instead of fatal so key rotation kicks in before giving up - Change notDebrid from fatal to temporary — "host may be down" is transient, all keys should be tried before failing - Raise parseRetryAfterMs cap from 2min to 1h — floodDetected mandates "retry after 1 hour" per API docs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/debrid.ts | 553 ++++++++++++++++++++++++++++++++++++------- tests/debrid.test.ts | 154 +++++++++++- 2 files changed, 622 insertions(+), 85 deletions(-) diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 451260c..630bac9 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -41,7 +41,14 @@ const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "ba const debridLinkKeyCooldowns = new Map(); type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip"; type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory }; +type DebridLinkRuntimeState = DebridLinkHostLimitInfo["state"]; +type DebridLinkRuntimeStatus = { + state: DebridLinkRuntimeState; + detail: string; + updatedAt: number; +}; const debridLinkKeyCooldownDetails = new Map(); +const debridLinkKeyRuntimeStatuses = 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; @@ -49,6 +56,7 @@ const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000; export function resetDebridLinkRuntimeStateForTests(): void { debridLinkKeyCooldowns.clear(); debridLinkKeyCooldownDetails.clear(); + debridLinkKeyRuntimeStatuses.clear(); } export function primeDebridLinkRuntimeCooldownForTests(keyId: string, cooldownMs: number, message = "Debrid-Link Key im Cooldown"): void { @@ -60,6 +68,31 @@ function clearDebridLinkKeyCooldownState(keyId: string): void { debridLinkKeyCooldownDetails.delete(keyId); } +function setDebridLinkKeyRuntimeStatus(keyId: string, state: DebridLinkRuntimeState, detail: string): void { + debridLinkKeyRuntimeStatuses.set(keyId, { + state, + detail: String(detail || "").trim(), + updatedAt: Date.now() + }); +} + +function getDebridLinkKeyRuntimeStatus(keyId: string): DebridLinkRuntimeStatus | null { + return debridLinkKeyRuntimeStatuses.get(keyId) || null; +} + +function mapDebridLinkCooldownCategoryToRuntimeState(category: DebridLinkCooldownCategory): DebridLinkRuntimeState { + if (category === "invalid") { + return "invalid"; + } + if (category === "quota") { + return "quota"; + } + if (category === "rate_limit") { + return "rate_limit"; + } + return "cooldown"; +} + function setDebridLinkKeyCooldownState( keyId: string, cooldownMs: number, @@ -72,6 +105,7 @@ function setDebridLinkKeyCooldownState( } debridLinkKeyCooldowns.set(keyId, Date.now() + Math.max(1000, Math.floor(cooldownMs))); debridLinkKeyCooldownDetails.set(keyId, { message, category }); + setDebridLinkKeyRuntimeStatus(keyId, mapDebridLinkCooldownCategoryToRuntimeState(category), message); } function getDebridLinkKeyCooldownState( @@ -296,14 +330,16 @@ function parseRetryAfterMs(value: string | null): number { return 0; } + // Cap at 1 hour — floodDetected can mandate "retry after 1 hour" + const maxRetryMs = 60 * 60 * 1000; const asSeconds = Number(text); if (Number.isFinite(asSeconds) && asSeconds >= 0) { - return Math.min(120000, Math.floor(asSeconds * 1000)); + return Math.min(maxRetryMs, Math.floor(asSeconds * 1000)); } const asDate = Date.parse(text); if (Number.isFinite(asDate)) { - return Math.min(120000, Math.max(0, asDate - Date.now())); + return Math.min(maxRetryMs, Math.max(0, asDate - Date.now())); } return 0; @@ -576,6 +612,194 @@ class DebridLinkApiError extends Error { } } +function toDebridLinkKeyStateLabel(state: DebridLinkHostLimitInfo["state"]): string { + if (state === "ready") { + return "Bereit"; + } + if (state === "cooldown") { + return "Cooldown"; + } + if (state === "invalid") { + return "Ungueltig"; + } + if (state === "quota") { + return "Quota"; + } + if (state === "rate_limit") { + return "Rate-Limit"; + } + if (state === "error") { + return "Fehler"; + } + return "Unbekannt"; +} + +function toDebridLinkHostStateLabel(state: DebridLinkHostLimitInfo["hostState"]): string { + if (state === "up") { + return "Online"; + } + if (state === "down") { + return "Offline"; + } + return "Unbekannt"; +} + +function shouldRetryDebridLinkApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean { + if (attempt >= maxAttempts) { + return false; + } + if (error.status === 429 || error.status >= 500) { + return true; + } + return DEBRID_LINK_RETRYABLE_ERRORS.has(error.code); +} + +function retryDelayForDebridLinkApiError(error: DebridLinkApiError, attempt: number): number { + if (error.retryAfterMs > 0) { + return error.retryAfterMs; + } + return retryDelay(attempt); +} + +async function requestDebridLinkPayloadWithKey( + apiKey: { token: string }, + method: "GET" | "POST" | "DELETE", + apiPath: string, + body: Record | undefined, + signal?: AbortSignal, + maxAttempts = REQUEST_RETRIES +): Promise> { + let lastTransportError = ""; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${apiKey.token}`, + "User-Agent": DEBRID_USER_AGENT + }; + let payloadBody: string | undefined; + if (method !== "GET" && method !== "DELETE" && body) { + headers["Content-Type"] = "application/json"; + payloadBody = JSON.stringify(body); + } + + const response = await fetch(`${DEBRID_LINK_API_BASE}${apiPath}`, { + method, + headers, + body: payloadBody, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const responseText = await response.text(); + const payload = parseJsonSafe(responseText); + if (!payload) { + const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText) + ? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})` + : compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`; + const error = new DebridLinkApiError( + response.status, + "requestError", + description, + parseRetryAfterMs(response.headers.get("retry-after")), + null + ); + if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) { + await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal); + continue; + } + throw error; + } + + if (!response.ok || !parseDebridLinkSuccess(payload)) { + const error = new DebridLinkApiError( + response.status, + parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`, + parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`, + parseRetryAfterMs(response.headers.get("retry-after")), + payload + ); + if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) { + await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal); + continue; + } + throw error; + } + + return payload; + } catch (error) { + if (error instanceof DebridLinkApiError) { + throw error; + } + lastTransportError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) { + throw error; + } + if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) { + throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen"); + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen"); +} + +async function fetchDebridLinkPublicHostInfo( + host: string, + signal?: AbortSignal +): Promise> { + const hostLabel = host.trim() || "rapidgator"; + try { + const response = await fetch(`${DEBRID_LINK_API_BASE}/downloader/hosts?keys=name,status,domains`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": DEBRID_USER_AGENT + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const responseText = await response.text(); + const payload = parseJsonSafe(responseText); + if (!response.ok || !payload || !parseDebridLinkSuccess(payload)) { + throw new Error(parseError(response.status, responseText, payload)); + } + const entries = Array.isArray(payload.value) + ? payload.value.map((entry) => asRecord(entry)).filter((entry): entry is Record => Boolean(entry)) + : []; + const wanted = normalizeDebridLinkHostKey(hostLabel); + const hostEntry = entries.find((entry) => { + const name = normalizeDebridLinkHostKey(pickString(entry, ["name"])); + if (name === wanted) { + return true; + } + const domains = Array.isArray(entry.domains) ? entry.domains.map((value) => normalizeDebridLinkHostKey(String(value || ""))) : []; + return domains.some((domain) => domain === wanted); + }); + if (!hostEntry) { + return { + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: `${hostLabel} nicht in /downloader/hosts gefunden.` + }; + } + const statusValue = Number(hostEntry.status ?? NaN); + const hostState: DebridLinkHostLimitInfo["hostState"] = Number.isFinite(statusValue) + ? (statusValue >= 1 ? "up" : "down") + : "unknown"; + return { + hostState, + hostStateLabel: toDebridLinkHostStateLabel(hostState), + hostNote: hostState === "down" + ? `${hostLabel} ist laut Debrid-Link /downloader/hosts aktuell offline.` + : `${hostLabel} ist laut Debrid-Link /downloader/hosts erreichbar.` + }; + } catch (error) { + return { + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: `Hoststatus konnte nicht geladen werden: ${compactErrorText(error)}` + }; + } +} + async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: string; token: string }, host: string, signal?: AbortSignal): Promise { let lastError = ""; const hostLabel = host.trim() || "rapidgator"; @@ -629,7 +853,16 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin trafficMaxBytes: null, linksCurrent: null, linksMax: null, - note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.` + note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.`, + state: "unknown", + stateLabel: toDebridLinkKeyStateLabel("unknown"), + stateDetail: "", + cooldownUntil: null, + cooldownRemainingMs: 0, + lastCheckedAt: Date.now(), + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: "" }; } @@ -644,7 +877,16 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin trafficMaxBytes: pickNumber(daySize, ["value", "max"]), linksCurrent: pickNumber(dayCount, ["current"]), linksMax: pickNumber(dayCount, ["value", "max"]), - note: "" + note: "", + state: "ready", + stateLabel: toDebridLinkKeyStateLabel("ready"), + stateDetail: "API erreichbar", + cooldownUntil: null, + cooldownRemainingMs: 0, + lastCheckedAt: Date.now(), + hostState: "unknown", + hostStateLabel: toDebridLinkHostStateLabel("unknown"), + hostNote: "" }; } catch (error) { lastError = compactErrorText(error); @@ -662,6 +904,172 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, "")); } +async function fetchDebridLinkHostLimitForKeyDetailed( + apiKey: { id: string; label: string; token: string }, + host: string, + publicHostInfo: Pick, + signal?: AbortSignal +): Promise { + const hostLabel = host.trim() || "rapidgator"; + const runtimeStatus = getDebridLinkKeyRuntimeStatus(apiKey.id); + const buildInfo = (overrides: Partial): DebridLinkHostLimitInfo => ({ + keyId: apiKey.id, + keyLabel: apiKey.label, + host: hostLabel, + fetchedAt: Date.now(), + trafficCurrentBytes: null, + trafficMaxBytes: null, + linksCurrent: null, + linksMax: null, + note: "", + state: runtimeStatus?.state || "unknown", + stateLabel: toDebridLinkKeyStateLabel(runtimeStatus?.state || "unknown"), + stateDetail: runtimeStatus?.detail || "", + cooldownUntil: null, + cooldownRemainingMs: 0, + lastCheckedAt: runtimeStatus?.updatedAt || null, + hostState: publicHostInfo.hostState, + hostStateLabel: publicHostInfo.hostStateLabel, + hostNote: publicHostInfo.hostNote, + ...overrides + }); + + const cooldownState = getDebridLinkKeyCooldownState(apiKey.id); + if (cooldownState) { + const state = mapDebridLinkCooldownCategoryToRuntimeState(cooldownState.category); + return buildInfo({ + state, + stateLabel: toDebridLinkKeyStateLabel(state), + stateDetail: cooldownState.message, + cooldownUntil: cooldownState.until, + cooldownRemainingMs: cooldownState.remainingMs, + lastCheckedAt: runtimeStatus?.updatedAt || Date.now(), + note: cooldownState.message + }); + } + + for (const apiPath of ["/downloader/limits/all", "/downloader/limits"]) { + try { + const payload = await requestDebridLinkPayloadWithKey(apiKey, "GET", apiPath, undefined, signal); + const hostEntry = findDebridLinkHostEntry(payload, hostLabel); + if (!hostEntry) { + if (apiPath.endsWith("/all")) { + continue; + } + clearDebridLinkKeyCooldownState(apiKey.id); + setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar"); + return buildInfo({ + state: "ready", + stateLabel: toDebridLinkKeyStateLabel("ready"), + stateDetail: "API erreichbar", + lastCheckedAt: Date.now(), + note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.` + }); + } + + const daySize = asRecord(hostEntry.daySize); + const dayCount = asRecord(hostEntry.dayCount); + clearDebridLinkKeyCooldownState(apiKey.id); + setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar"); + return buildInfo({ + host: pickString(hostEntry, ["name", "host"]) || hostLabel, + trafficCurrentBytes: pickNumber(daySize, ["current"]), + trafficMaxBytes: pickNumber(daySize, ["value", "max"]), + linksCurrent: pickNumber(dayCount, ["current"]), + linksMax: pickNumber(dayCount, ["value", "max"]), + state: "ready", + stateLabel: toDebridLinkKeyStateLabel("ready"), + stateDetail: "API erreichbar", + lastCheckedAt: Date.now(), + note: "" + }); + } catch (error) { + if (error instanceof DebridLinkApiError && error.status === 404 && apiPath.endsWith("/all")) { + continue; + } + + const checkedAt = Date.now(); + if (error instanceof DebridLinkApiError) { + const code = String(error.code || "").trim() || `HTTP ${error.status}`; + const description = error.message || code; + + if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) { + const detail = `API-Key ungueltig oder deaktiviert (${code}: ${description})`; + setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, detail, "invalid"); + const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt); + return buildInfo({ + state: "invalid", + stateLabel: toDebridLinkKeyStateLabel("invalid"), + stateDetail: detail, + cooldownUntil: nextCooldown?.until || null, + cooldownRemainingMs: nextCooldown?.remainingMs || 0, + lastCheckedAt: checkedAt, + note: detail + }); + } + + if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) { + const detail = `API-Rate-Limit erreicht (${code}: ${description})`; + setDebridLinkKeyCooldownState(apiKey.id, error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS, detail, "rate_limit"); + const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt); + return buildInfo({ + state: "rate_limit", + stateLabel: toDebridLinkKeyStateLabel("rate_limit"), + stateDetail: detail, + cooldownUntil: nextCooldown?.until || null, + cooldownRemainingMs: nextCooldown?.remainingMs || 0, + lastCheckedAt: checkedAt, + note: detail + }); + } + + if (DEBRID_LINK_QUOTA_ERRORS.has(code)) { + const detail = `Quota erreicht (${code}: ${description})`; + setDebridLinkKeyCooldownState(apiKey.id, parseDebridLinkNextResetMs(error.payload) || DEBRID_LINK_KEY_COOLDOWN_MS, detail, "quota"); + const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt); + return buildInfo({ + state: "quota", + stateLabel: toDebridLinkKeyStateLabel("quota"), + stateDetail: detail, + cooldownUntil: nextCooldown?.until || null, + cooldownRemainingMs: nextCooldown?.remainingMs || 0, + lastCheckedAt: checkedAt, + note: detail + }); + } + + const detail = `${code}: ${description}`; + setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail); + return buildInfo({ + state: "error", + stateLabel: toDebridLinkKeyStateLabel("error"), + stateDetail: detail, + lastCheckedAt: checkedAt, + note: detail + }); + } + + const detail = compactErrorText(error).replace(/^Error:\s*/i, "") || `Debrid-Link Limits fuer ${apiKey.label} fehlgeschlagen`; + setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail); + return buildInfo({ + state: "error", + stateLabel: toDebridLinkKeyStateLabel("error"), + stateDetail: detail, + lastCheckedAt: checkedAt, + note: detail + }); + } + } + + return buildInfo({ + state: "unknown", + stateLabel: toDebridLinkKeyStateLabel("unknown"), + stateDetail: `Keine Limits fuer ${apiKey.label} gefunden`, + lastCheckedAt: Date.now(), + note: `Keine Limits fuer ${apiKey.label} gefunden` + }); +} + function uniqueProviderOrder(order: readonly DebridProvider[]): DebridProvider[] { const seen = new Set(); const result: DebridProvider[] = []; @@ -1841,9 +2249,10 @@ export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapi throw new Error("Debrid-Link ist nicht konfiguriert"); } + const publicHostInfo = await fetchDebridLinkPublicHostInfo(host, signal); const results: DebridLinkHostLimitInfo[] = []; for (const apiKey of apiKeys) { - results.push(await fetchDebridLinkHostLimitForKey(apiKey, host, signal)); + results.push(await fetchDebridLinkHostLimitForKeyDetailed(apiKey, host, publicHostInfo, signal)); } return results; } @@ -1870,6 +2279,7 @@ class DebridLinkClient { let usableKeySeen = false; const cooldownFailures: string[] = []; let earliestCooldownUntil = 0; + const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = []; // 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. @@ -1898,6 +2308,7 @@ class DebridLinkClient { try { const result = await this.unrestrictWithKey(apiKey, link, signal); clearDebridLinkKeyCooldownState(apiKey.id); + setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich"); logger.info(`Debrid-Link${keyLabel}: Unrestrict OK -> ${result.fileName || "?"}`); return { ...result, @@ -1907,11 +2318,17 @@ class DebridLinkClient { }; } catch (error) { const failure = await this.classifyKeyFailure(error, apiKey, link, signal); + attemptedKeyFailures.push({ + message: `Debrid-Link${keyLabel}: ${failure.message}`, + cooldownMs: failure.cooldownMs, + category: failure.category + }); failures.push(`Debrid-Link${keyLabel}: ${failure.message}`); if (failure.cooldownMs > 0) { setDebridLinkKeyCooldownState(apiKey.id, failure.cooldownMs, failure.message, failure.category || "temporary"); } else { clearDebridLinkKeyCooldownState(apiKey.id); + setDebridLinkKeyRuntimeStatus(apiKey.id, failure.category === "invalid" ? "invalid" : "error", failure.message); } if (failure.fatal) { throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`); @@ -1928,7 +2345,17 @@ class DebridLinkClient { 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("debrid_link_no_active_key:Debrid-Link: Kein aktiver API-Key verfuegbar"); + } + + if (attemptedKeyFailures.length > 0 && attemptedKeyFailures.every((entry) => entry.category === "invalid")) { + throw new Error(`debrid_link_invalid_all:${attemptedKeyFailures.map((entry) => entry.message).join(" | ")}`); + } + + const cooldownOnlyFailures = attemptedKeyFailures.filter((entry) => entry.cooldownMs > 0); + if (attemptedKeyFailures.length > 0 && cooldownOnlyFailures.length === attemptedKeyFailures.length) { + const retryMs = Math.max(1000, Math.min(...cooldownOnlyFailures.map((entry) => Math.max(1000, entry.cooldownMs))) + 1000); + throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownOnlyFailures.map((entry) => entry.message).join(" | ")}`); } throw new Error(failures.join(" | ") || "Debrid-Link: Kein aktiver API-Key verfuegbar"); } @@ -1961,77 +2388,7 @@ class DebridLinkClient { signal?: AbortSignal, maxAttempts = REQUEST_RETRIES ): Promise> { - let lastTransportError = ""; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - const headers: Record = { - Accept: "application/json", - Authorization: `Bearer ${apiKey.token}`, - "User-Agent": DEBRID_USER_AGENT - }; - let payloadBody: string | undefined; - if (method !== "GET" && method !== "DELETE" && body) { - headers["Content-Type"] = "application/json"; - payloadBody = JSON.stringify(body); - } - - const response = await fetch(`${DEBRID_LINK_API_BASE}${apiPath}`, { - method, - headers, - body: payloadBody, - signal: withTimeoutSignal(signal, API_TIMEOUT_MS) - }); - const responseText = await response.text(); - const payload = parseJsonSafe(responseText); - if (!payload) { - const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText) - ? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})` - : compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`; - const error = new DebridLinkApiError( - response.status, - "requestError", - description, - parseRetryAfterMs(response.headers.get("retry-after")), - null - ); - if (this.shouldRetryApiError(error, attempt, maxAttempts)) { - await sleepWithSignal(this.retryDelayForApiError(error, attempt), signal); - continue; - } - throw error; - } - - if (!response.ok || !parseDebridLinkSuccess(payload)) { - const error = new DebridLinkApiError( - response.status, - parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`, - parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`, - parseRetryAfterMs(response.headers.get("retry-after")), - payload - ); - if (this.shouldRetryApiError(error, attempt, maxAttempts)) { - await sleepWithSignal(this.retryDelayForApiError(error, attempt), signal); - continue; - } - throw error; - } - - return payload; - } catch (error) { - if (error instanceof DebridLinkApiError) { - throw error; - } - lastTransportError = compactErrorText(error); - if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) { - throw error; - } - if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) { - throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen"); - } - await sleepWithSignal(retryDelay(attempt), signal); - } - } - throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen"); + return requestDebridLinkPayloadWithKey(apiKey, method, apiPath, body, signal, maxAttempts); } private shouldRetryApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean { @@ -2084,8 +2441,28 @@ class DebridLinkClient { if (!id) { return chosen; } - const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal); - return refreshed || chosen; + + // Poll up to 5 times with 2s delay — Debrid-Link sometimes needs a few + // seconds to generate the download URL after /downloader/add. + const maxPolls = 5; + for (let poll = 0; poll < maxPolls; poll++) { + if (signal?.aborted) { + throw new Error("aborted"); + } + if (poll > 0) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal); + if (refreshed) { + const url = pickString(refreshed, ["downloadUrl"]); + const expired = refreshed.expired === true; + if (url && !expired) { + return refreshed; + } + } + } + // Return last fetched entry (caller will detect missing URL and throw) + return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen; } private async fetchDownloaderEntry( @@ -2152,11 +2529,12 @@ class DebridLinkClient { }; } if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) { + // notDebrid = "host may be down" — transient, try next key before giving up. return { - fatal: true, - cooldownMs: 0, + fatal: false, + cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, message: `Link kann aktuell nicht generiert werden (${code}: ${description})`, - category: "skip" + category: "temporary" }; } if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) { @@ -2189,6 +2567,17 @@ class DebridLinkClient { }; } + // Treat missing/expired download URLs as temporary — the server may need + // more time or another key might succeed immediately. + if (/keine gueltige download-url/i.test(errorText)) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, + message: errorText || "Download-URL nicht verfuegbar", + category: "temporary" + }; + } + if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) { return { fatal: false, diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index e501aae..3056ae0 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -462,7 +462,70 @@ describe("debrid service", () => { expect(addCalls).toBe(2); }); - it("fails fast on provider-wide Debrid-Link notDebrid errors without rotating through all keys", async () => { + it("returns an invalid-all marker when all Debrid-Link keys are invalid", 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 + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record | undefined)?.Authorization || ""); + } + authHeaders.push(authHeader); + return new Response(JSON.stringify({ + success: false, + error: "badToken", + error_description: "token expired" + }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/all-invalid.bin")).rejects.toThrow(/debrid_link_invalid_all:/i); + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + }); + + it("returns a clear error when all Debrid-Link keys are locally exhausted", async () => { + const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + 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, + debridLinkApiKeyDailyLimitBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + debridLinkApiKeyDailyUsageBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/no-key-left.bin")).rejects.toThrow(/debrid-link nicht verfuegbar|kein aktiver api-key/i); + }); + + it("rotates through all keys on Debrid-Link notDebrid errors before failing", async () => { const settings = { ...defaultSettings(), debridLinkApiKeys: "dl-key-one\ndl-key-two", @@ -488,8 +551,9 @@ describe("debrid service", () => { }) as typeof fetch; const service = new DebridService(settings); - await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow("Link kann aktuell nicht generiert werden (notDebrid: notDebrid)"); - expect(authHeaders).toEqual(["Bearer dl-key-one"]); + await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/notDebrid/); + // notDebrid is transient (host may be down) — both keys should be tried + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); }); it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => { @@ -828,6 +892,90 @@ describe("debrid service", () => { expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true); }); + it("includes Debrid-Link host and key state diagnostics in host limits", async () => { + 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/hosts")) { + return new Response(JSON.stringify({ + success: true, + value: [ + { + name: "rapidgator", + status: 1, + domains: ["rapidgator.net", "rg.to"] + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + daySize: { current: 1024, value: 2048 }, + dayCount: { current: 2, value: 5 } + } + ] + } + }), { + 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[0].state).toBe("ready"); + expect(info[0].stateLabel).toBe("Bereit"); + expect(info[0].hostState).toBe("up"); + expect(info[0].hostStateLabel).toBe("Online"); + }); + + it("returns invalid Debrid-Link key diagnostics instead of failing the whole popup request", async () => { + 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/hosts")) { + return new Response(JSON.stringify({ + success: true, + value: [ + { + name: "rapidgator", + status: 0, + domains: ["rapidgator.net"] + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: false, + error: "badToken", + error_description: "token expired" + }), { + status: 401, + 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].state).toBe("invalid"); + expect(info[0].cooldownRemainingMs).toBeGreaterThan(0); + expect(info[0].hostState).toBe("down"); + expect(info[0].hostStateLabel).toBe("Offline"); + }); + it("uses AllDebrid web path when enabled", async () => { const settings = { ...defaultSettings(),