Debrid-Link: per-(key, host) cooldown for maxLinkHost / maxDataHost

Previously, when a Debrid-Link key returned maxDataHost or maxLinkHost
("you've used up YOUR per-host quota for this hoster on this key"), the
WHOLE key got a 2-min key-wide cooldown — blocking it for all hosters
even though it was only exhausted for that one host.

Now those errors apply a per-(key, host) cooldown instead:
- Key 1 hits maxDataHost on rapidgator → only (Key 1, rapidgator) is
  blocked. Key 1 stays usable for uploaded.net etc. in the same rotation
- Same key + same host on subsequent attempts: skipped with explicit
  "Host-Cooldown rapidgator bis HH:MM:SS" log line
- Key-wide quotas (maxLink, maxData) still apply key-wide as before

Implementation:
- DEBRID_LINK_QUOTA_ERRORS split into key-wide vs host-only sets
- New debridLinkKeyHostCooldowns map keyed by `${keyId}|${hoster}`
- setDebridLinkKeyHostCooldownState mirrors max-wins / strong-category
  semantics of the per-key version, falls back to key-wide cooldown when
  the hoster can't be parsed (safer than thrashing)
- Key runtime status stays "ready" on host-only failures — only this
  (key, host) is blocked, the key is still healthy for other hosters
- Reset/prune helpers (resetDebridLinkRuntimeStateForTests,
  pruneDebridLinkRuntimeStateForKeys, pruneExpiredDebridLinkRuntimeState)
  clear the new map too
- New rotation log event SKIP_HOST_COOLDOWN

Test: 2 keys, key1 hits maxDataHost on rapidgator → key2 succeeds.
Second rapidgator request: key1 SKIPPED via host-cooldown.
Third request to uploaded.net: key1 tried again and succeeds.
This commit is contained in:
Sucukdeluxe 2026-04-19 23:41:07 +02:00
parent 3a8be961b0
commit d62fa548cb
2 changed files with 254 additions and 6 deletions

View File

@ -22,7 +22,15 @@ 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"]);
/** Truly key-wide quota errors: the whole key is exhausted regardless of host. */
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
/** Per-(key, host) quota errors: only this host is exhausted for this key the
* key remains usable for other hosters. */
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
/** Backward-compat union includes BOTH key-wide and per-host quota codes.
* Use this only for "is it a quota error of any kind?" checks; for behavior
* branches use the more specific sets above. */
const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]);
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"]);
@ -58,6 +66,8 @@ export function resetDebridLinkRuntimeStateForTests(): void {
debridLinkKeyCooldowns.clear();
debridLinkKeyCooldownDetails.clear();
debridLinkKeyRuntimeStatuses.clear();
debridLinkKeyHostCooldowns.clear();
debridLinkKeyHostCooldownDetails.clear();
}
/** Drop all Debrid-Link cooldown/runtime entries for key IDs that are no
@ -75,6 +85,17 @@ export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): v
debridLinkKeyRuntimeStatuses.delete(keyId);
}
}
// Per-(key, host) cooldown keys have format `${keyId}|${hoster}` — drop any
// whose keyId is no longer in the active set so removed keys don't keep
// memory state around if they're re-added later.
for (const stateKey of debridLinkKeyHostCooldowns.keys()) {
const sepIdx = stateKey.indexOf("|");
const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
if (!activeKeyIds.has(keyId)) {
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
}
}
}
/** Periodic cleanup of expired Debrid-Link cooldown/runtime entries.
@ -96,6 +117,13 @@ export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number {
removed += 1;
}
}
for (const [stateKey, until] of debridLinkKeyHostCooldowns) {
if (until + grace < now) {
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
removed += 1;
}
}
return removed;
}
@ -192,6 +220,92 @@ function getDebridLinkKeyCooldownState(
};
}
/** Per-(key, host) cooldown cache. When a key hits maxLinkHost / maxDataHost
* for a specific host, only that combination should be blocked the key
* itself stays usable for other hosters. Map key format: `${keyId}|${hoster}`. */
const debridLinkKeyHostCooldowns = new Map<string, number>();
const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
function makeDebridLinkKeyHostCooldownKey(keyId: string, hoster: string): string {
return `${keyId}|${hoster.toLowerCase()}`;
}
function clearDebridLinkKeyHostCooldownState(keyId: string, hoster: string): void {
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
}
function setDebridLinkKeyHostCooldownState(
keyId: string,
hoster: string,
cooldownMs: number,
message: string,
category: DebridLinkCooldownCategory
): void {
if (!hoster) {
// Fall back to key-wide cooldown when we can't determine the hoster — better
// a slightly broader block than letting the key thrash on the same failure.
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
return;
}
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
clearDebridLinkKeyHostCooldownState(keyId, hoster);
return;
}
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
// Same max-wins semantics as setDebridLinkKeyCooldownState — parallel items
// hitting maxDataHost on the same (key, host) shouldn't shorten an existing
// longer cooldown. Strong categories (quota / rate_limit / invalid) win over
// generic temporary regardless of duration.
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
const newIsStrongCategory = category === "rate_limit" || category === "quota" || category === "invalid";
const existingIsStrongCategory = existingDetail
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
: false;
if (existingUntil > Date.now()) {
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
return;
}
if (existingIsStrongCategory && !newIsStrongCategory) {
return;
}
}
debridLinkKeyHostCooldowns.set(stateKey, newUntil);
debridLinkKeyHostCooldownDetails.set(stateKey, { message, category });
// Intentionally NOT updating setDebridLinkKeyRuntimeStatus here — the key
// is still healthy for other hosters, only this (key, host) is blocked.
}
function getDebridLinkKeyHostCooldownState(
keyId: string,
hoster: string,
now = Date.now()
): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null {
if (!hoster) {
return null;
}
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
const until = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
if (!until) {
return null;
}
if (until <= now) {
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
return null;
}
const detail = debridLinkKeyHostCooldownDetails.get(stateKey);
return {
until,
remainingMs: until - now,
message: detail?.message || "Debrid-Link Key fuer Host im Cooldown",
category: detail?.category || "quota"
};
}
/** Per-account cooldown cache for Mega-Debrid: accountId → expiry timestamp. */
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory };
@ -2457,6 +2571,7 @@ class DebridLinkClient {
const totalKeys = this.apiKeys.length;
const providerName = "Debrid-Link";
const linkShort = String(link || "").slice(0, 80);
const linkHoster = extractHosterFromUrl(link);
// 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.
@ -2491,6 +2606,25 @@ class DebridLinkClient {
}
continue;
}
// Per-(key, host) cooldown — set when a previous attempt for THIS host
// returned maxLinkHost / maxDataHost. The key itself is healthy for other
// hosters, so we only skip it for this specific link.
const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null;
if (hostCooldownState) {
const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
logger.info(`Debrid-Link${keyLabel}: uebersprungen (Host-Cooldown ${linkHoster} bis ${untilStr}), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_HOST_COOLDOWN", {
reason: hostCooldownState.message,
category: hostCooldownState.category,
host: linkHoster,
until: untilStr
});
cooldownFailures.push(`Debrid-Link${keyLabel}: ${hostCooldownState.message}`);
if (!earliestCooldownUntil || hostCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = hostCooldownState.until;
}
continue;
}
// CLEAR per-key TEST log line BEFORE the network call, so the user
// can always see exactly which key is currently being tested for
@ -2527,7 +2661,21 @@ class DebridLinkClient {
});
failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) {
if (failure.hostOnly) {
// Per-(key, host) quota — block only this combination, not the
// whole key. The key remains "ready" for other hosters. If the
// hoster couldn't be parsed from the URL, the helper falls back
// to a key-wide cooldown (better safe than thrashing).
setDebridLinkKeyHostCooldownState(
apiKey.id,
failure.hoster || "",
failure.cooldownMs,
failure.message,
failure.category || "quota"
);
} else {
setDebridLinkKeyCooldownState(apiKey.id, failure.cooldownMs, failure.message, failure.category || "temporary");
}
} else {
clearDebridLinkKeyCooldownState(apiKey.id);
setDebridLinkKeyRuntimeStatus(apiKey.id, failure.category === "invalid" ? "invalid" : "error", failure.message);
@ -2750,7 +2898,7 @@ class DebridLinkClient {
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
link: string,
signal?: AbortSignal
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean }> {
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
if (error instanceof DebridLinkApiError) {
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
@ -2772,13 +2920,29 @@ class DebridLinkClient {
category: "rate_limit"
};
}
if (DEBRID_LINK_QUOTA_ERRORS.has(code)) {
if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) {
// Per-(key, host) quota — only this host is exhausted for this key.
// The key remains usable for other hosters, so we mark the failure
// hostOnly and let the rotation loop apply a per-(key, host) cooldown.
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
const hoster = extractHosterFromUrl(link) || "host";
const hosterRaw = extractHosterFromUrl(link);
const hosterLabel = hosterRaw || "host";
return {
fatal: false,
cooldownMs,
message: `Quota erreicht fuer ${hoster} (${code}: ${description})`,
message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`,
category: "quota",
hostOnly: true,
hoster: hosterRaw
};
}
if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) {
// Key-wide quota — whole key is exhausted, blocks all hosters.
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
return {
fatal: false,
cooldownMs,
message: `Quota erreicht (${code}: ${description})`,
category: "quota"
};
}

View File

@ -388,6 +388,90 @@ describe("debrid service", () => {
expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin");
});
it("scopes Debrid-Link maxDataHost cooldown to the (key, host) pair so the key stays usable for other hosters", 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 unrestrictAuthHeaders: 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")) {
return new Response(JSON.stringify({
success: true,
value: { nextResetSeconds: { value: 900 } }
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
// Only count calls to /downloader/add (the unrestrict endpoint)
if (url.includes("/downloader/add")) {
unrestrictAuthHeaders.push(authHeader);
// Read the body to know which link is being unrestricted
const bodyText = init?.body ? String(init.body) : "";
const isRapidgator = /rapidgator/i.test(bodyText);
// Only key-one + rapidgator returns maxDataHost. All other (key, host)
// combinations succeed.
if (authHeader === "Bearer dl-key-one" && isRapidgator) {
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/${authHeader.slice(-3)}-${isRapidgator ? "rg" : "ot"}.bin`,
name: "ok.bin",
size: 1024
}
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
// 1) First rapidgator: key-one hits maxDataHost → key-two succeeds.
const r1 = await service.unrestrictLink("https://rapidgator.net/file/first");
expect(r1.providerLabel).toContain("Key 2");
// 2) Second rapidgator request: key-one MUST be skipped (host cooldown
// on (key1, rapidgator)), only key-two should be tried.
unrestrictAuthHeaders.length = 0;
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
expect(r2.providerLabel).toContain("Key 2");
// 3) Different host: key-one must NOT be skipped — its host-cooldown is
// only for rapidgator, not for uploaded.net.
unrestrictAuthHeaders.length = 0;
const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
expect(r3.providerLabel).toContain("Key 1");
});
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
const settings = {
...defaultSettings(),