Harden Debrid-Link key failover and pending-state handling
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
cbb3694dd3
commit
c5dd6f4f30
@ -41,7 +41,14 @@ const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "ba
|
||||
const debridLinkKeyCooldowns = new Map<string, number>();
|
||||
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<string, DebridLinkCooldownDetail>();
|
||||
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
|
||||
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<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 (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<Pick<DebridLinkHostLimitInfo, "hostState" | "hostStateLabel" | "hostNote">> {
|
||||
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<string, unknown> => 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<DebridLinkHostLimitInfo> {
|
||||
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<DebridLinkHostLimitInfo, "hostState" | "hostStateLabel" | "hostNote">,
|
||||
signal?: AbortSignal
|
||||
): Promise<DebridLinkHostLimitInfo> {
|
||||
const hostLabel = host.trim() || "rapidgator";
|
||||
const runtimeStatus = getDebridLinkKeyRuntimeStatus(apiKey.id);
|
||||
const buildInfo = (overrides: Partial<DebridLinkHostLimitInfo>): 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<DebridProvider>();
|
||||
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<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");
|
||||
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,
|
||||
|
||||
@ -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<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: "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<Response> => {
|
||||
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<Response> => {
|
||||
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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user