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:
parent
3a8be961b0
commit
d62fa548cb
@ -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) {
|
||||
setDebridLinkKeyCooldownState(apiKey.id, failure.cooldownMs, failure.message, failure.category || "temporary");
|
||||
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"
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user