Account-rotation logging + transient cooldown fixes

- New dedicated account-rotation.log (audit-style) so multi-account/key
  rotation flow is visible without rd_downloader.log noise
- Mega-Debrid: always show "Account X/Y (masked@login)" label even with
  one account, and log a clear "TESTE Account fuer Link-Generierung..."
  line BEFORE every network call so the user sees which account is in
  play even if the call hangs
- Mega-Debrid: on FAILED, log which account will be tried next
  (skipping disabled/limited/cooldown ones), so the rotation is auditable
- Mega-Web "Antwort leer" / empty body now uses 20s cooldown instead of
  120s — empty responses are typically transient server hiccups, not
  real failures (caused healthy accounts to be unfairly blocked)
- Generic transport/unknown errors: 30s cooldown instead of 120s
- Global stall watchdog no longer counts items in "validating" status —
  per-item validating watchdog already handles them and gives the
  multi-account rotation enough time. Without this fix the global
  watchdog could abort the unrestrict mid-rotation (e.g. account 3 of 3
  still being tested) just because no download bytes had arrived
- Same logging treatment applied to Debrid-Link key rotation
This commit is contained in:
Sucukdeluxe 2026-04-19 23:03:22 +02:00
parent 4d2f11a96d
commit 25aa48fe99
4 changed files with 314 additions and 26 deletions

View File

@ -0,0 +1,147 @@
import fs from "node:fs";
import path from "node:path";
/** Dedicated log file for multi-account/key rotation events:
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
* test result, cooldown set, fallback to next account/key, etc.
* Separate from rd_downloader.log so the user can see the rotation flow
* without the noise of normal download activity. */
type RotationLevel = "INFO" | "WARN" | "ERROR";
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
let rotationLogPath: string | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
export function initAccountRotationLog(baseDir: string): void {
rotationLogPath = path.join(baseDir, "account-rotation.log");
try {
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
cleanupOldBackup(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
rotateIfNeeded(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
fs.appendFileSync(
rotationLogPath,
`=== Account-Rotation Log Start: ${new Date().toISOString()} ===\n`,
"utf8"
);
} catch {
rotationLogPath = null;
}
}
/** Record an account/key rotation event. The format is intentionally compact
* and grep-friendly: timestamp + level + provider + accountLabel + event + fields.
* Example output:
* 2026-04-19T20:48:50.000Z [INFO] Mega-Debrid Web | Account 2 (fa**david@...) | TEST | link=https://...
* 2026-04-19T20:48:52.000Z [WARN] Mega-Debrid Web | Account 2 (fa**david@...) | FAILED reason="Antwort leer" cooldownSec=30 | link=https://...
* 2026-04-19T20:48:53.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | TEST | link=https://...
* 2026-04-19T20:48:55.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | OK directLink=https://... | link=https://... */
export function logAccountRotation(
level: RotationLevel,
provider: string,
accountLabel: string,
event: string,
fields?: Record<string, unknown>
): void {
if (!rotationLogPath) {
return;
}
try {
rotateIfNeeded(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
const head = `${new Date().toISOString()} [${level}] ${provider} | ${accountLabel} | ${event}`;
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
} catch {
// ignore write errors
}
}
export function getAccountRotationLogPath(): string | null {
if (!rotationLogPath) {
return null;
}
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
}
export function shutdownAccountRotationLog(): void {
if (!rotationLogPath) {
return;
}
try {
fs.appendFileSync(
rotationLogPath,
`=== Account-Rotation Log Ende: ${new Date().toISOString()} ===\n`,
"utf8"
);
} catch {
// ignore
}
rotationLogPath = null;
}

View File

@ -37,6 +37,7 @@ import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } fro
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
@ -84,6 +85,7 @@ export class AppController {
initPackageLogs(this.storagePaths.baseDir); initPackageLogs(this.storagePaths.baseDir);
initItemLogs(this.storagePaths.baseDir); initItemLogs(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir); initAuditLog(this.storagePaths.baseDir);
initAccountRotationLog(this.storagePaths.baseDir);
initRenameLog(this.storagePaths.baseDir); initRenameLog(this.storagePaths.baseDir);
initTraceLog(this.storagePaths.baseDir); initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
@ -679,6 +681,7 @@ export class AppController {
shutdownRenameLog(); shutdownRenameLog();
this.audit("INFO", "App beendet"); this.audit("INFO", "App beendet");
shutdownTraceLog(); shutdownTraceLog();
shutdownAccountRotationLog();
shutdownAuditLog(); shutdownAuditLog();
if (this.settings.historyRetentionMode === "session") { if (this.settings.historyRetentionMode === "session") {
clearHistory(this.storagePaths); clearHistory(this.storagePaths);

View File

@ -4,6 +4,7 @@ import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostL
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
import { logAccountRotation } from "./account-rotation-log";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
@ -1808,26 +1809,39 @@ class MegaDebridClient {
let usableAccountSeen = false; let usableAccountSeen = false;
const cooldownFailures: string[] = []; const cooldownFailures: string[] = [];
let earliestCooldownUntil = 0; let earliestCooldownUntil = 0;
const hasMultiple = accounts.length > 1; const totalAccounts = accounts.length;
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
const linkShort = String(link || "").slice(0, 80);
// Always start from first account — use first available, skip disabled/limited/cooldown. // Always start from first account — use first available, skip disabled/limited/cooldown.
for (let idx = 0; idx < accounts.length; idx += 1) { for (let idx = 0; idx < accounts.length; idx += 1) {
const account = accounts[idx]; const account = accounts[idx];
const accountLabel = hasMultiple ? ` (${account.label})` : ""; // Always show account number — even with 1 account — so user can tell at a
// glance which account is in play. Format: "(Account 2/3, fa**david@...)"
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
if (isMegaDebridAccountDisabled(settings, account.id)) { if (isMegaDebridAccountDisabled(settings, account.id)) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`); logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
continue; continue;
} }
if (isMegaDebridAccountDailyLimitReached(settings, account.id)) { if (isMegaDebridAccountDailyLimitReached(settings, account.id)) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Account`); logger.info(`Mega-Debrid${accountLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
continue; continue;
} }
// Cooldown key includes mode so API failures don't block Web attempts // Cooldown key includes mode so API failures don't block Web attempts
const cooldownKey = `${account.id}:${mode}`; const cooldownKey = `${account.id}:${mode}`;
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey); const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
if (accountCooldownState) { if (accountCooldownState) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (Cooldown bis ${new Date(accountCooldownState.until).toLocaleTimeString()}), pruefe naechsten Account`); const untilStr = new Date(accountCooldownState.until).toLocaleTimeString();
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (Cooldown bis ${untilStr}), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
reason: accountCooldownState.message,
category: accountCooldownState.category,
until: untilStr
});
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`); cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) { if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = accountCooldownState.until; earliestCooldownUntil = accountCooldownState.until;
@ -1835,20 +1849,34 @@ class MegaDebridClient {
continue; continue;
} }
// CLEAR per-account TEST log line BEFORE the network call, so the user
// can always see exactly which account is currently being tested for
// link generation — even if the call hangs or times out.
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now();
usableAccountSeen = true; usableAccountSeen = true;
try { try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal); const result = await client.unrestrictLink(link, signal);
clearMegaDebridAccountCooldownState(cooldownKey); clearMegaDebridAccountCooldownState(cooldownKey);
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK -> ${result.fileName || "?"}`); const elapsedMs = Date.now() - testStartedAt;
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
elapsedMs,
fileName: result.fileName || "",
link: linkShort
});
return { return {
...result, ...result,
sourceLabel: account.label, sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`,
sourceAccountId: account.id, sourceAccountId: account.id,
sourceAccountLabel: account.label sourceAccountLabel: account.label
}; };
} catch (error) { } catch (error) {
const failure = MegaDebridClient.classifyAccountFailure(error); const failure = MegaDebridClient.classifyAccountFailure(error);
const elapsedMs = Date.now() - testStartedAt;
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`); failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) { if (failure.cooldownMs > 0) {
setMegaDebridAccountCooldownState(cooldownKey, failure.cooldownMs, failure.message, failure.category); setMegaDebridAccountCooldownState(cooldownKey, failure.cooldownMs, failure.message, failure.category);
@ -1856,12 +1884,35 @@ class MegaDebridClient {
clearMegaDebridAccountCooldownState(cooldownKey); clearMegaDebridAccountCooldownState(cooldownKey);
} }
if (failure.fatal) { if (failure.fatal) {
logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", {
elapsedMs,
reason: failure.message,
category: failure.category,
link: linkShort
});
throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`); throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
} }
const cooldownInfo = failure.cooldownMs > 0 const cooldownInfo = failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: ""; : "";
logger.warn(`Mega-Debrid${accountLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Account`); // Find the next account that will be tried (for clearer log)
let nextLabel = "ENDE";
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
const nextAcc = accounts[nextIdx];
if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`;
break;
}
}
logger.warn(`Mega-Debrid${accountLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Account (${nextLabel})`);
logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
elapsedMs,
reason: failure.message,
category: failure.category,
cooldownSec: failure.cooldownMs > 0 ? Math.ceil(failure.cooldownMs / 1000) : 0,
next: nextLabel,
link: linkShort
});
} }
} }
@ -1924,11 +1975,25 @@ class MegaDebridClient {
}; };
} }
// Temporary/transport errors — short cooldown, try next account // Mega-Web "Antwort leer" / empty body — server frequently returns transient
// empty responses that recover within seconds. A 2-minute cooldown for this
// is way too long because the account is fundamentally healthy. Use a short
// 20s cooldown so the next download attempt can use this account again.
if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) {
return {
fatal: false,
cooldownMs: 20_000,
message: errorText || "Mega-Web transient empty response",
category: "temporary"
};
}
// Temporary/transport errors — short cooldown, try next account.
// Plain network blips deserve a much shorter cooldown than 2 min.
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) { if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, cooldownMs: 30_000,
message: errorText || "temporaerer Fehler", message: errorText || "temporaerer Fehler",
category: "temporary" category: "temporary"
}; };
@ -1937,7 +2002,7 @@ class MegaDebridClient {
// Unknown errors — short cooldown, try next account (non-fatal) // Unknown errors — short cooldown, try next account (non-fatal)
return { return {
fatal: false, fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, cooldownMs: 30_000,
message: errorText || "unbekannter Fehler", message: errorText || "unbekannter Fehler",
category: "temporary" category: "temporary"
}; };
@ -2389,23 +2454,37 @@ class DebridLinkClient {
let earliestCooldownUntil = 0; let earliestCooldownUntil = 0;
const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = []; const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = [];
let consecutiveTransportFailures = 0; let consecutiveTransportFailures = 0;
const totalKeys = this.apiKeys.length;
const providerName = "Debrid-Link";
const linkShort = String(link || "").slice(0, 80);
// Always start from first key — use first available, skip disabled/limited/cooldown. // 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. // This ensures all parallel items use the same key until it's actually exhausted.
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) { for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
const apiKey = this.apiKeys[keyIdx]; const apiKey = this.apiKeys[keyIdx];
const keyLabel = this.apiKeys.length > 1 ? ` (${apiKey.label})` : ""; // Always show key number — even with 1 key — so user can tell at a
// glance which key is in play. Format: "(Key 2/3, abc***xyz)"
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`); logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
continue; continue;
} }
if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) { if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) {
logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`); logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
continue; continue;
} }
const keyCooldownState = getDebridLinkKeyCooldownState(apiKey.id); const keyCooldownState = getDebridLinkKeyCooldownState(apiKey.id);
if (keyCooldownState) { if (keyCooldownState) {
logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${new Date(keyCooldownState.until).toLocaleTimeString()}), pruefe naechsten Key`); const untilStr = new Date(keyCooldownState.until).toLocaleTimeString();
logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${untilStr}), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
reason: keyCooldownState.message,
category: keyCooldownState.category,
until: untilStr
});
cooldownFailures.push(`Debrid-Link${keyLabel}: ${keyCooldownState.message}`); cooldownFailures.push(`Debrid-Link${keyLabel}: ${keyCooldownState.message}`);
if (!earliestCooldownUntil || keyCooldownState.until < earliestCooldownUntil) { if (!earliestCooldownUntil || keyCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = keyCooldownState.until; earliestCooldownUntil = keyCooldownState.until;
@ -2413,12 +2492,25 @@ class DebridLinkClient {
continue; 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
// link generation — even if the call hangs or times out.
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now();
usableKeySeen = true; usableKeySeen = true;
try { try {
const result = await this.unrestrictWithKey(apiKey, link, signal); const result = await this.unrestrictWithKey(apiKey, link, signal);
clearDebridLinkKeyCooldownState(apiKey.id); clearDebridLinkKeyCooldownState(apiKey.id);
setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich"); setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich");
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK -> ${result.fileName || "?"}`); const elapsedMs = Date.now() - testStartedAt;
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
elapsedMs,
fileName: result.fileName || "",
link: linkShort
});
return { return {
...result, ...result,
sourceLabel: apiKey.label, sourceLabel: apiKey.label,
@ -2427,6 +2519,7 @@ class DebridLinkClient {
}; };
} catch (error) { } catch (error) {
const failure = await this.classifyKeyFailure(error, apiKey, link, signal); const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
const elapsedMs = Date.now() - testStartedAt;
attemptedKeyFailures.push({ attemptedKeyFailures.push({
message: `Debrid-Link${keyLabel}: ${failure.message}`, message: `Debrid-Link${keyLabel}: ${failure.message}`,
cooldownMs: failure.cooldownMs, cooldownMs: failure.cooldownMs,
@ -2440,6 +2533,12 @@ class DebridLinkClient {
setDebridLinkKeyRuntimeStatus(apiKey.id, failure.category === "invalid" ? "invalid" : "error", failure.message); setDebridLinkKeyRuntimeStatus(apiKey.id, failure.category === "invalid" ? "invalid" : "error", failure.message);
} }
if (failure.fatal) { if (failure.fatal) {
logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", {
elapsedMs,
reason: failure.message,
category: failure.category,
link: linkShort
});
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`); throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
} }
if (failure.providerWide) { if (failure.providerWide) {
@ -2447,6 +2546,13 @@ class DebridLinkClient {
// Break immediately and apply a longer cooldown (5 min) to avoid burning all keys. // Break immediately and apply a longer cooldown (5 min) to avoid burning all keys.
const providerWideCooldownMs = 5 * 60 * 1000; const providerWideCooldownMs = 5 * 60 * 1000;
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`); logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
elapsedMs,
reason: failure.message,
category: failure.category,
cooldownSec: Math.ceil(providerWideCooldownMs / 1000),
link: linkShort
});
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`); throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
} }
// Track consecutive transport failures (timeout/network) to detect cascades. // Track consecutive transport failures (timeout/network) to detect cascades.
@ -2456,12 +2562,35 @@ class DebridLinkClient {
// 2+ keys timed out in a row — likely a server/network issue, not key-specific. // 2+ keys timed out in a row — likely a server/network issue, not key-specific.
const cascadeCooldownMs = 3 * 60 * 1000; const cascadeCooldownMs = 3 * 60 * 1000;
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`); logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
elapsedMs,
consecutive: consecutiveTransportFailures,
cooldownSec: Math.ceil(cascadeCooldownMs / 1000),
link: linkShort
});
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`); throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
} }
// Find the next key that will be tried (for clearer log)
let nextLabel = "ENDE";
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
const nextKey = this.apiKeys[nextIdx];
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`;
break;
}
}
const cooldownInfo = failure.cooldownMs > 0 const cooldownInfo = failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: ""; : "";
logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key`); logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key (${nextLabel})`);
logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
elapsedMs,
reason: failure.message,
category: failure.category,
cooldownSec: failure.cooldownMs > 0 ? Math.ceil(failure.cooldownMs / 1000) : 0,
next: nextLabel,
link: linkShort
});
} }
} }

View File

@ -7660,6 +7660,15 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
// Only items that are actually DOWNLOADING (have an open HTTP body that
// should be making progress) count toward the global stall watchdog.
// Items in "validating" are still in the unrestrict phase — they're handled
// by the per-item validating watchdog above (VALIDATING_STUCK_MS) which
// gives the multi-account/multi-key rotation enough time. Without this
// exclusion the global watchdog would abort the unrestrict mid-rotation
// (e.g. account 3 of 3 still being tested) just because no bytes had been
// downloaded recently — which is correct, since unrestrict doesn't
// produce download bytes.
let stalledCount = 0; let stalledCount = 0;
let diskBlockedCount = 0; let diskBlockedCount = 0;
for (const active of this.activeTasks.values()) { for (const active of this.activeTasks.values()) {
@ -7671,7 +7680,7 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const item = this.session.items[active.itemId]; const item = this.session.items[active.itemId];
if (item && (item.status === "downloading" || item.status === "validating")) { if (item && item.status === "downloading") {
stalledCount += 1; stalledCount += 1;
} }
} }
@ -7689,7 +7698,7 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const item = this.session.items[active.itemId]; const item = this.session.items[active.itemId];
if (item && (item.status === "downloading" || item.status === "validating")) { if (item && item.status === "downloading") {
active.abortReason = "stall"; active.abortReason = "stall";
active.abortController.abort("stall"); active.abortController.abort("stall");
} }