Fix Debrid-Link key rotation cascade failure, case-sensitive rename, and sample filter
- notDebrid (host-level) no longer burns all keys: stops rotation immediately with 5min cooldown instead of cycling through all 9 keys pointlessly - Remove double provider-blockade: debrid_link_cooldown no longer stacks recordProviderFailure + applyProviderBusyBackoff on top of key cooldowns - Detect timeout cascades: 2+ consecutive transport failures trigger 3min cooldown instead of burning remaining keys - Case-sensitive rename: files with different casing (e.g. lowercase scene names) now get properly renamed instead of being skipped as "already matching" - Extended sample filter: detect -s.mkv suffix and \Sample\ subdirectories in auto-rename (already worked in MKV-move) - Add key status display with state pills in Debrid-Link key stats popup - Add parseDebridLinkTerminalFailure for fast-fail on exhausted keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1d0b2ee8e3
commit
38179881f5
+21
-3
@@ -2280,6 +2280,7 @@ class DebridLinkClient {
|
||||
const cooldownFailures: string[] = [];
|
||||
let earliestCooldownUntil = 0;
|
||||
const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = [];
|
||||
let consecutiveTransportFailures = 0;
|
||||
|
||||
// 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.
|
||||
@@ -2333,6 +2334,22 @@ class DebridLinkClient {
|
||||
if (failure.fatal) {
|
||||
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
|
||||
}
|
||||
if (failure.providerWide) {
|
||||
// Host-level issue (e.g. notDebrid) — rotating to other keys is pointless.
|
||||
// Break immediately and apply a longer cooldown (5 min) to avoid burning all keys.
|
||||
const providerWideCooldownMs = 5 * 60 * 1000;
|
||||
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
|
||||
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
|
||||
}
|
||||
// Track consecutive transport failures (timeout/network) to detect cascades.
|
||||
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
|
||||
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
|
||||
if (consecutiveTransportFailures >= 2) {
|
||||
// 2+ keys timed out in a row — likely a server/network issue, not key-specific.
|
||||
const cascadeCooldownMs = 3 * 60 * 1000;
|
||||
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
|
||||
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
|
||||
}
|
||||
const cooldownInfo = failure.cooldownMs > 0
|
||||
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
||||
: "";
|
||||
@@ -2496,7 +2513,7 @@ class DebridLinkClient {
|
||||
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
|
||||
link: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory }> {
|
||||
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean }> {
|
||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
if (error instanceof DebridLinkApiError) {
|
||||
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
|
||||
@@ -2529,12 +2546,13 @@ class DebridLinkClient {
|
||||
};
|
||||
}
|
||||
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
|
||||
// notDebrid = "host may be down" — transient, try next key before giving up.
|
||||
// notDebrid = host-level issue — affects ALL keys equally, do NOT rotate.
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||
message: `Link kann aktuell nicht generiert werden (${code}: ${description})`,
|
||||
category: "temporary"
|
||||
category: "temporary",
|
||||
providerWide: true
|
||||
};
|
||||
}
|
||||
if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) {
|
||||
|
||||
@@ -525,7 +525,8 @@ function isPermanentLinkError(errorText: string): boolean {
|
||||
|
||||
function isUnrestrictFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return text.includes("unrestrict") || text.includes("mega-web") || text.includes("mega-debrid")
|
||||
return text.includes("unrestrict") || text.includes("debrid-link") || text.includes("debrid_link_")
|
||||
|| text.includes("mega-web") || text.includes("mega-debrid")
|
||||
|| text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid")
|
||||
|| text.includes("session-cookie") || text.includes("session cookie") || text.includes("session blockiert")
|
||||
|| text.includes("session expired") || text.includes("invalid session")
|
||||
@@ -546,6 +547,26 @@ function parseDebridLinkCooldownRetry(errorText: string): { delayMs: number; det
|
||||
return { delayMs, detail };
|
||||
}
|
||||
|
||||
function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
|
||||
const raw = String(errorText || "");
|
||||
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
|
||||
if (!match) {
|
||||
if (/debrid-link.+(deaktiviert|ausgeschopft|kein aktiver api-key)/i.test(raw)) {
|
||||
return {
|
||||
kind: "no_active_key",
|
||||
detail: raw.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const kind = String(match[1] || "").toLowerCase() === "invalid_all" ? "invalid_all" : "no_active_key";
|
||||
const detail = String(match[2] || "").trim();
|
||||
return {
|
||||
kind,
|
||||
detail: detail || "Debrid-Link ist aktuell nicht verfuegbar"
|
||||
};
|
||||
}
|
||||
|
||||
function isProviderBusyUnrestrictError(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return text.includes("too many active")
|
||||
@@ -3310,6 +3331,9 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||
const sampleDirNames = new Set(["sample", "samples"]);
|
||||
// Short suffix pattern: scene groups often use "-s.mkv" for samples (e.g. itn-continuum.s01e10.720p-s.mkv)
|
||||
const sampleSuffixRe = /[._\-]s$/i;
|
||||
for (const sourcePath of videoFiles) {
|
||||
if (shouldAbort?.()) {
|
||||
return renamed;
|
||||
@@ -3317,11 +3341,12 @@ export class DownloadManager extends EventEmitter {
|
||||
const sourceName = path.basename(sourcePath);
|
||||
const sourceExt = path.extname(sourceName);
|
||||
const sourceBaseName = path.basename(sourceName, sourceExt);
|
||||
const parentDirName = path.basename(path.dirname(sourcePath)).toLowerCase();
|
||||
|
||||
// Skip sample files — renaming them strips the "-sample" suffix,
|
||||
// making them indistinguishable from the main MKV and causing (2)
|
||||
// duplicates during MKV collection.
|
||||
if (sampleTokenRe.test(sourceBaseName)) {
|
||||
if (sampleTokenRe.test(sourceBaseName) || sampleDirNames.has(parentDirName) || sampleSuffixRe.test(sourceBaseName)) {
|
||||
continue;
|
||||
}
|
||||
const folderCandidates: string[] = [];
|
||||
@@ -3427,7 +3452,8 @@ export class DownloadManager extends EventEmitter {
|
||||
logger.warn(`Auto-Rename übersprungen (Zielpfad zu lang/ungültig): ${sourcePath}`);
|
||||
continue;
|
||||
}
|
||||
if (pathKey(targetPath) === pathKey(sourcePath)) {
|
||||
if (targetPath === sourcePath) {
|
||||
// Exact match (including casing) — truly nothing to do.
|
||||
if (pkg) {
|
||||
const resolved = resolveRenameItem(targetPath);
|
||||
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename übersprungen: Name bereits passend", {
|
||||
@@ -3439,6 +3465,27 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (pathKey(targetPath) === pathKey(sourcePath) && targetPath !== sourcePath) {
|
||||
// Same file on case-insensitive FS but different casing — rename in-place.
|
||||
// On Windows, fs.rename handles case-only renames correctly.
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
renamedCount += 1;
|
||||
if (pkg) {
|
||||
const resolved = resolveRenameItem(targetPath);
|
||||
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename (Casing korrigiert)", {
|
||||
sourcePath,
|
||||
sourceName,
|
||||
targetPath,
|
||||
targetBaseName
|
||||
}, resolved.item, resolved.matchedBy);
|
||||
}
|
||||
logger.info(`Auto-Rename Casing: ${sourcePath} -> ${targetPath}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Auto-Rename Casing fehlgeschlagen: ${sourcePath} -> ${targetPath}: ${compactErrorText(err as Error)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (await this.existsAsync(targetPath)) {
|
||||
if (pkg) {
|
||||
this.logPackageForPackage(pkg, "WARN", "Auto-Rename übersprungen: Ziel existiert", {
|
||||
@@ -8055,14 +8102,28 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const debridLinkTerminalFailure = parseDebridLinkTerminalFailure(errorText);
|
||||
if (debridLinkTerminalFailure) {
|
||||
item.status = "failed";
|
||||
this.recordRunOutcome(item.id, "failed");
|
||||
item.lastError = debridLinkTerminalFailure.detail;
|
||||
item.fullStatus = `Debrid-Link: ${debridLinkTerminalFailure.detail}`;
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
this.retryStateByItem.delete(item.id);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
||||
const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText);
|
||||
if (debridLinkCooldown) {
|
||||
active.unrestrictRetries += 1;
|
||||
item.retries += 1;
|
||||
const failureProvider = this.getProviderFailureKeyForItem(item);
|
||||
this.recordProviderFailure(failureProvider);
|
||||
this.applyProviderBusyBackoff(failureProvider, debridLinkCooldown.delayMs);
|
||||
// Do NOT call recordProviderFailure/applyProviderBusyBackoff here —
|
||||
// Debrid-Link key cooldowns are managed in debrid.ts per-key.
|
||||
// Adding a provider-wide cooldown on top causes double-blocking.
|
||||
logger.warn(
|
||||
`Debrid-Link-Cooldown: item=${item.fileName || item.id}, ` +
|
||||
`retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${debridLinkCooldown.delayMs}ms, ` +
|
||||
|
||||
Reference in New Issue
Block a user