diff --git a/src/main/debrid.ts b/src/main/debrid.ts index d082e04..e398d45 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -20,12 +20,31 @@ const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1"; const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i; const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2"; -const DEBRID_LINK_QUOTA_ERRORS = new Set(["maxLink", "maxLinkHost", "maxData", "maxDataHost", "maxAttempts", "maxTransfer"]); +const DEBRID_LINK_QUOTA_ERRORS = new Set(["maxLink", "maxLinkHost", "maxData", "maxDataHost"]); +const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]); +const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]); +const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]); /** Errors where the key can't handle this link — skip to next key immediately, no retries */ -const DEBRID_LINK_SKIP_KEY_ERRORS = new Set(["notDebrid", "disabledServerHost", "notFree"]); +const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([ + "notDebrid", + "disabledServerHost", + "notFreeHost", + "serverNotAllowed", + "freeServerOverload", + "maintenanceHost", + "noServerHost", + "fileNotAvailable" +]); +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(); 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(); +} const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; @@ -358,6 +377,75 @@ function findDebridLinkHostEntry(payload: Record | null, host: return null; } +function parseDebridLinkErrorCode(payload: Record | null): string { + return pickString(payload, ["error", "ERR"]); +} + +function parseDebridLinkErrorDescription(payload: Record | null): string { + return pickString(payload, ["error_description", "message", "detail", "response_text", "error", "ERR"]); +} + +function looksLikeHtmlResponse(contentType: string, body: string): boolean { + const type = String(contentType || "").toLowerCase(); + if (type.includes("text/html") || type.includes("application/xhtml+xml")) { + return true; + } + return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || "")); +} + +function parsePositiveNumber(value: unknown): number | null { + const numeric = Number(value ?? NaN); + if (!Number.isFinite(numeric) || numeric <= 0) { + return null; + } + return Math.floor(numeric); +} + +function parseDebridLinkNextResetMs(payload: Record | null): number { + const value = asRecord(payload?.value); + const nextReset = value?.nextResetSeconds; + const nextResetRecord = asRecord(nextReset); + const seconds = parsePositiveNumber(nextReset) + ?? parsePositiveNumber(nextResetRecord?.current) + ?? parsePositiveNumber(nextResetRecord?.value); + if (!seconds) { + return 0; + } + return Math.min(24 * 60 * 60 * 1000, seconds * 1000); +} + +function parseDebridLinkLinkEntries(value: unknown): Record[] { + if (Array.isArray(value)) { + return value + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + } + const entry = asRecord(value); + return entry ? [entry] : []; +} + +class DebridLinkApiError extends Error { + public readonly status: number; + public readonly code: string; + public readonly retryAfterMs: number; + public readonly payload: Record | null; + + public constructor( + status: number, + code: string, + description: string, + retryAfterMs: number, + payload: Record | null + ) { + super(description || code || `HTTP ${status || 0}`); + this.name = "DebridLinkApiError"; + this.status = status; + this.code = code; + this.retryAfterMs = retryAfterMs; + this.payload = payload; + } +} + async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: string; token: string }, host: string, signal?: AbortSignal): Promise { let lastError = ""; const hostLabel = host.trim() || "rapidgator"; @@ -1477,6 +1565,9 @@ class DebridLinkClient { throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar (deaktiviert oder am Tageslimit)"); } + const failures: string[] = []; + let usableKeySeen = false; + // 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. for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) { @@ -1496,6 +1587,32 @@ class DebridLinkClient { continue; } + usableKeySeen = true; + try { + const result = await this.unrestrictWithKey(apiKey, link, signal); + logger.info(`Debrid-Link${keyLabel}: Unrestrict OK -> ${result.fileName || "?"}`); + return { + ...result, + sourceLabel: apiKey.label, + sourceAccountId: apiKey.id, + sourceAccountLabel: apiKey.label + }; + } catch (error) { + 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); + } + if (failure.fatal) { + throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`); + } + const cooldownInfo = failure.cooldownMs > 0 + ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` + : ""; + logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key`); + } + continue; + let lastError = ""; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { if (signal?.aborted) throw new Error("aborted:debrid"); @@ -1584,7 +1701,268 @@ class DebridLinkClient { } } - throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar"); + if (!usableKeySeen) { + throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar"); + } + throw new Error(failures.join(" | ") || "Debrid-Link: Kein aktiver API-Key verfuegbar"); + } + + private async unrestrictWithKey( + apiKey: ReturnType[number], + link: string, + signal?: AbortSignal + ): Promise { + const payload = await this.requestPayload(apiKey, "POST", "/downloader/add", { url: link }, signal); + const entry = await this.resolveDownloaderEntry(apiKey, payload.value, link, signal); + const directUrl = pickString(entry, ["downloadUrl"]); + const expired = Boolean(entry.expired === true); + if (!directUrl || expired) { + throw new Error("Debrid-Link: Keine gueltige Download-URL in Antwort"); + } + return { + fileName: pickString(entry, ["name"]) || filenameFromUrl(directUrl) || filenameFromUrl(link), + directUrl, + fileSize: pickNumber(entry, ["size"]), + retriesUsed: 0 + }; + } + + private async requestPayload( + apiKey: ReturnType[number], + 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 (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"); + } + + private shouldRetryApiError(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); + } + + private retryDelayForApiError(error: DebridLinkApiError, attempt: number): number { + if (error.retryAfterMs > 0) { + return error.retryAfterMs; + } + return retryDelay(attempt); + } + + private async resolveDownloaderEntry( + apiKey: ReturnType[number], + rawValue: unknown, + originalLink: string, + signal?: AbortSignal + ): Promise> { + const entries = parseDebridLinkLinkEntries(rawValue); + if (entries.length === 0) { + throw new Error("Debrid-Link: Keine Daten in Antwort"); + } + + const matchingEntries = entries.filter((entry) => { + const url = pickString(entry, ["url"]); + return url ? canonicalLink(url) === canonicalLink(originalLink) : false; + }); + const chosen = matchingEntries.length === 1 + ? matchingEntries[0] + : entries.length === 1 + ? entries[0] + : null; + if (!chosen) { + throw new Error(`Debrid-Link: Link lieferte ${entries.length} Dateien statt einer Einzeldatei`); + } + + const needsRefresh = !pickString(chosen, ["downloadUrl"]) || chosen.expired === true; + if (!needsRefresh) { + return chosen; + } + + const id = pickString(chosen, ["id"]); + if (!id) { + return chosen; + } + const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal); + return refreshed || chosen; + } + + private async fetchDownloaderEntry( + apiKey: ReturnType[number], + id: string, + signal?: AbortSignal + ): Promise | null> { + const query = new URLSearchParams({ ids: id }); + const payload = await this.requestPayload(apiKey, "GET", `/downloader/list?${query.toString()}`, undefined, signal); + const entries = parseDebridLinkLinkEntries(payload.value); + if (entries.length === 0) { + return null; + } + return entries.find((entry) => pickString(entry, ["id"]) === id) || entries[0] || null; + } + + private async fetchQuotaCooldownMs( + apiKey: ReturnType[number], + signal?: AbortSignal + ): Promise { + try { + const payload = await this.requestPayload(apiKey, "GET", "/downloader/limits", undefined, signal, 1); + return parseDebridLinkNextResetMs(payload) || DEBRID_LINK_KEY_COOLDOWN_MS; + } catch { + return DEBRID_LINK_KEY_COOLDOWN_MS; + } + } + + private async classifyKeyFailure( + error: unknown, + apiKey: ReturnType[number], + link: string, + signal?: AbortSignal + ): Promise<{ fatal: boolean; cooldownMs: number; message: string }> { + const errorText = compactErrorText(error).replace(/^Error:\s*/i, ""); + 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)) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, + message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})` + }; + } + 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})` + }; + } + if (DEBRID_LINK_QUOTA_ERRORS.has(code)) { + const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal); + const hoster = extractHosterFromUrl(link) || "host"; + return { + fatal: false, + cooldownMs, + message: `Quota erreicht fuer ${hoster} (${code}: ${description})` + }; + } + 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})` + }; + } + if (DEBRID_LINK_FATAL_LINK_ERRORS.has(code)) { + return { + fatal: true, + cooldownMs: 0, + message: description + }; + } + if (DEBRID_LINK_RETRYABLE_ERRORS.has(code) || error.status >= 500) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, + message: `temporärer API-Fehler (${code}: ${description})` + }; + } + return { + fatal: true, + cooldownMs: 0, + message: description + }; + } + + if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) { + return { + fatal: false, + cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, + message: errorText || "temporärer Transportfehler" + }; + } + + return { + fatal: true, + cooldownMs: 0, + message: errorText || "Unbekannter Debrid-Link-Fehler" + }; } } diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 2dd05b2..a044c05 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -2,12 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; -import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid"; +import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid"; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; + resetDebridLinkRuntimeStateForTests(); vi.restoreAllMocks(); }); @@ -177,6 +178,252 @@ describe("debrid service", () => { expect(result.providerLabel).toContain("Key 2"); }); + it("uses JSON add payload and refreshes missing Debrid-Link downloadUrl via downloader/list", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let addBody = ""; + let addContentType = ""; + let addAccept = ""; + const calledUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): 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/add")) { + const headers = init?.headers; + if (headers instanceof Headers) { + addContentType = headers.get("Content-Type") || ""; + addAccept = headers.get("Accept") || ""; + } else if (Array.isArray(headers)) { + addContentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] || ""; + addAccept = headers.find(([key]) => key.toLowerCase() === "accept")?.[1] || ""; + } else { + addContentType = String((headers as Record | undefined)?.["Content-Type"] || ""); + addAccept = String((headers as Record | undefined)?.Accept || ""); + } + addBody = String(init?.body || ""); + return new Response(JSON.stringify({ + success: true, + value: { + id: "dl-link-1", + url: "https://hoster.example/file.bin", + name: "file.bin", + expired: true + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1")) { + return new Response(JSON.stringify({ + success: true, + value: [ + { + id: "dl-link-1", + url: "https://hoster.example/file.bin", + name: "file.bin", + downloadUrl: "https://debrid-link.example/file.bin", + size: 1234, + expired: false + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + + expect(addContentType).toBe("application/json"); + expect(addAccept).toBe("application/json"); + expect(addBody).toBe(JSON.stringify({ url: "https://hoster.example/file.bin" })); + expect(result.provider).toBe("debridlink"); + expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); + expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true); + }); + + it("rotates to the next Debrid-Link key when the first key is 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); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "badToken", + error_description: "token expired" + }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/valid.bin", + name: "valid.bin", + size: 2048 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/needs-rotation.bin"); + + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + expect(result.directUrl).toBe("https://debrid-link.example/valid.bin"); + }); + + it("looks up limits and rotates keys when Debrid-Link host quota is reached", 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 limitCalls = 0; + const authHeaders: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + 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 || ""); + } + + if (url.includes("debrid-link.com/api/v2/downloader/limits")) { + limitCalls += 1; + return new Response(JSON.stringify({ + success: true, + value: { + nextResetSeconds: { value: 900 } + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + authHeaders.push(authHeader); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "maxDataHost", + error_description: "host quota reached" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/quota-ok.bin", + name: "quota-ok.bin", + size: 4096 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/quota-test"); + + expect(limitCalls).toBe(1); + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin"); + }); + + it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", 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: "badFilePassword", + error_description: "wrong password" + }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/protected.bin")).rejects.toThrow("wrong password"); + expect(authHeaders).toEqual(["Bearer dl-key-one"]); + }); + it("uses BestDebrid auth header without token query fallback", async () => { const settings = { ...defaultSettings(),