diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 630bac9..785e600 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -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[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)) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index bc8af47..6e3113e 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -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, ` + diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index dfc8363..31bb49e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1121,6 +1121,76 @@ function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undef return info.note || "Nicht verfügbar"; } +function getDebridLinkKeyStatusDisplay( + key: DebridLinkAccountKeyEntry, + info: DebridLinkHostLimitInfo | null | undefined +): { label: string; tone: "ok" | "warn" | "bad" | "muted"; title: string } { + if (key.disabled) { + return { + label: "Deaktiviert", + tone: "muted", + title: "Key ist manuell deaktiviert." + }; + } + if (key.dailyLimitReached) { + return { + label: "Lokales Limit", + tone: "warn", + title: key.dailyLimitBytes > 0 + ? `Lokales Tageslimit erreicht (${humanSize(key.dailyUsedBytes)} / ${humanSize(key.dailyLimitBytes)}).` + : "Lokales Tageslimit erreicht." + }; + } + if (!info) { + return { + label: "Pruefe...", + tone: "muted", + title: "Live-Status wird geladen." + }; + } + + const title = [info.stateDetail, info.note, info.hostNote] + .filter((value) => Boolean(String(value || "").trim())) + .join("\n"); + + if (info.state === "ready") { + if (info.hostState === "down") { + return { + label: "Host offline", + tone: "warn", + title: title || "Der Hoster ist laut Debrid-Link aktuell offline." + }; + } + return { + label: "Bereit", + tone: "ok", + title: title || "Key ist nutzbar." + }; + } + + if (info.state === "invalid" || info.state === "error") { + return { + label: info.stateLabel, + tone: "bad", + title: title || info.stateLabel + }; + } + + if (info.state === "quota" || info.state === "rate_limit" || info.state === "cooldown") { + return { + label: info.stateLabel, + tone: "warn", + title: title || info.stateLabel + }; + } + + return { + label: info.stateLabel || "Unbekannt", + tone: "muted", + title: title || info.stateLabel || "Unbekannt" + }; +} + interface BandwidthChartProps { items: Record; running: boolean; @@ -5913,7 +5983,13 @@ export function App(): ReactElement { const totalUsed = entry.debridLinkKeys.reduce((s, k) => s + k.dailyUsedBytes, 0); const limitedCount = entry.debridLinkKeys.filter((k) => k.dailyLimitReached).length; const disabledCount = entry.debridLinkKeys.filter((k) => k.disabled).length; + const keyDiagnostics = entry.debridLinkKeys + .map((k) => debridLinkHostLimits[k.id]) + .filter((info): info is DebridLinkHostLimitInfo => Boolean(info)); const loadedQuotaCount = entry.debridLinkKeys.filter((k) => Boolean(debridLinkHostLimits[k.id])).length; + const invalidCount = keyDiagnostics.filter((info) => info.state === "invalid").length; + const cooldownCount = keyDiagnostics.filter((info) => info.state === "cooldown" || info.state === "quota" || info.state === "rate_limit").length; + const hostStatusLabel = keyDiagnostics.find((info) => info.hostState !== "unknown")?.hostStateLabel || ""; return (
setKeyStatsPopup(null)}>
e.stopPropagation()}> @@ -5924,8 +6000,10 @@ export function App(): ReactElement { {entry.debridLinkKeys.length} Keys · Heute: {humanSize(totalUsed)} {limitedCount > 0 && · {limitedCount} am Limit} {disabledCount > 0 && · {disabledCount} deaktiviert} + {invalidCount > 0 && · {invalidCount} invalid} + {cooldownCount > 0 && · {cooldownCount} im Cooldown} {debridLinkHostLimitsLoading && · Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})} - {!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && · Rapidgator API-Quota} + {!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && · Rapidgator {hostStatusLabel || "Status unbekannt"}} {debridLinkHostLimitsError && · API-Quota konnte nicht geladen werden}

@@ -5937,14 +6015,16 @@ export function App(): ReactElement { Key Heute Lokal + Status RG Traffic RG Links
{entry.debridLinkKeys.map((key, ki) => ( -
+
{(() => { const hostInfo = debridLinkHostLimits[key.id]; + const statusDisplay = getDebridLinkKeyStatusDisplay(key, hostInfo); return ( <> {ki + 1} @@ -5961,6 +6041,7 @@ export function App(): ReactElement { {humanSize(key.dailyUsedBytes)} {key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"} + {statusDisplay.label} {formatDebridLinkTraffic(hostInfo)} {formatDebridLinkCountQuota(hostInfo)} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 48aedc4..14fd387 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1562,10 +1562,12 @@ body, .account-subkey-table-head .col-usage, .account-subkey-table-head .col-limit, +.account-subkey-table-head .col-status, .account-subkey-table-head .col-traffic, .account-subkey-table-head .col-links, .account-subkey-table-row .col-usage, .account-subkey-table-row .col-limit, +.account-subkey-table-row .col-status, .account-subkey-table-row .col-traffic, .account-subkey-table-row .col-links { text-align: right; @@ -1615,6 +1617,10 @@ body, color: var(--muted); } +.account-subkey-table-row .col-status { + text-align: center; +} + .account-subkey-table-row .col-traffic, .account-subkey-table-row .col-links { text-align: center; @@ -1630,10 +1636,42 @@ body, gap: 6px; } -.account-subkey-table-row .col-action .btn { - padding: 1px 6px; - font-size: 10px; -} +.account-subkey-table-row .col-action .btn { + padding: 1px 6px; + font-size: 10px; +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 88px; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.status-pill-ok { + color: #166534; + background: color-mix(in srgb, #22c55e 16%, transparent); +} + +.status-pill-warn { + color: #92400e; + background: color-mix(in srgb, #f59e0b 18%, transparent); +} + +.status-pill-bad { + color: #991b1b; + background: color-mix(in srgb, #ef4444 14%, transparent); +} + +.status-pill-muted { + color: var(--muted); + background: color-mix(in srgb, var(--field) 80%, transparent); +} .btn-sm { padding: 3px 8px; diff --git a/src/shared/types.ts b/src/shared/types.ts index 23a1253..145fc97 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -294,8 +294,10 @@ export interface UpdateInstallProgress { message: string; } -export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown"; -export type AllDebridHostInfoSource = "api" | "web"; +export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown"; +export type AllDebridHostInfoSource = "api" | "web"; +export type DebridLinkHostState = "up" | "down" | "unknown"; +export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown"; export interface AllDebridHostInfo { host: string; @@ -311,17 +313,26 @@ export interface AllDebridHostInfo { note: string; } -export interface DebridLinkHostLimitInfo { - keyId: string; - keyLabel: string; - host: string; - fetchedAt: number; +export interface DebridLinkHostLimitInfo { + keyId: string; + keyLabel: string; + host: string; + fetchedAt: number; trafficCurrentBytes: number | null; - trafficMaxBytes: number | null; - linksCurrent: number | null; - linksMax: number | null; - note: string; -} + trafficMaxBytes: number | null; + linksCurrent: number | null; + linksMax: number | null; + note: string; + state: DebridLinkKeyState; + stateLabel: string; + stateDetail: string; + cooldownUntil: number | null; + cooldownRemainingMs: number; + lastCheckedAt: number | null; + hostState: DebridLinkHostState; + hostStateLabel: string; + hostNote: string; +} export interface ParsedHashEntry { fileName: string; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 3056ae0..5d4fc2d 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -525,7 +525,7 @@ describe("debrid service", () => { 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 () => { + it("stops rotation immediately on Debrid-Link notDebrid (provider-wide) — does NOT burn remaining keys", async () => { const settings = { ...defaultSettings(), debridLinkApiKeys: "dl-key-one\ndl-key-two", @@ -551,9 +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(/notDebrid/); - // notDebrid is transient (host may be down) — both keys should be tried - expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/); + // notDebrid is a host-level issue — only Key 1 should be tried, Key 2 must NOT be burned + expect(authHeaders).toEqual(["Bearer dl-key-one"]); }); it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 29c6961..0a165c7 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1798,6 +1798,54 @@ describe("download manager", () => { await manager.stop(); }); + it("fails fast when Debrid-Link has no active api key left", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + 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, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 0, + autoExtract: false, + debridLinkApiKeyDailyLimitBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + debridLinkApiKeyDailyUsageBytes: { + [keys[0].id]: 100, + [keys[1].id]: 100 + }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + const manager = new DownloadManager( + settings, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]); + await manager.start(); + await waitFor(() => { + const item = Object.values(manager.getSnapshot().session.items)[0]; + return Boolean(item && item.status === "failed"); + }, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("failed"); + expect(item?.fullStatus || "").toContain("Debrid-Link"); + expect(item?.retries).toBe(0); + + await manager.stop(); + }); + it("restarts from zero after repeated resume underflow on fresh direct links", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);