Rewrite Debrid-Link v2 unrestrict flow
This commit is contained in:
parent
f828a871e2
commit
78b06f2975
@ -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<string, number>();
|
||||
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<string, unknown> | null, host:
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDebridLinkErrorCode(payload: Record<string, unknown> | null): string {
|
||||
return pickString(payload, ["error", "ERR"]);
|
||||
}
|
||||
|
||||
function parseDebridLinkErrorDescription(payload: Record<string, unknown> | 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<string, unknown> | 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<string, unknown>[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry): entry is Record<string, unknown> => 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<string, unknown> | null;
|
||||
|
||||
public constructor(
|
||||
status: number,
|
||||
code: string,
|
||||
description: string,
|
||||
retryAfterMs: number,
|
||||
payload: Record<string, unknown> | 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<DebridLinkHostLimitInfo> {
|
||||
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,8 +1701,269 @@ class DebridLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
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<typeof parseDebridLinkApiKeys>[number],
|
||||
link: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<UnrestrictedLink> {
|
||||
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<typeof parseDebridLinkApiKeys>[number],
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
apiPath: string,
|
||||
body: Record<string, unknown> | undefined,
|
||||
signal?: AbortSignal,
|
||||
maxAttempts = REQUEST_RETRIES
|
||||
): Promise<Record<string, unknown>> {
|
||||
let lastTransportError = "";
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
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<typeof parseDebridLinkApiKeys>[number],
|
||||
rawValue: unknown,
|
||||
originalLink: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<typeof parseDebridLinkApiKeys>[number],
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, unknown> | 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<typeof parseDebridLinkApiKeys>[number],
|
||||
signal?: AbortSignal
|
||||
): Promise<number> {
|
||||
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<typeof parseDebridLinkApiKeys>[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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── LinkSnappy Client ──
|
||||
|
||||
@ -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<Response> => {
|
||||
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<string, unknown> | undefined)?.["Content-Type"] || "");
|
||||
addAccept = String((headers as Record<string, unknown> | 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<Response> => {
|
||||
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<string, unknown> | 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<Response> => {
|
||||
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<string, unknown> | 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<Response> => {
|
||||
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<string, unknown> | 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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user