diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2ce28..7a5f228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.34] - 2026-08-13 + +### Account recovery and rotation + +- Added a visible cooldown reset action for Mega-Debrid accounts that clears account cooldowns, shared Web-session backoff, Mega-Debrid provider circuit breakers, and affected queued retries without interrupting active downloads. +- Treated ambiguous Mega-Debrid Web login and blocked-session responses as one shared session backoff instead of incorrectly cooling every configured account. +- Prevented repeated account cycling while a shared Mega-Debrid Web session failure is active and allowed queued work to resume through the normal scheduler after recovery. +- Attributed fallback failures and cooldowns to the provider that actually handled the failed attempt. + +### Settings reliability + +- Accepted omitted optional settings fields during provider changes so adding a Debrid-Link account after disabling Mega-Debrid accounts no longer fails payload validation. +- Added complete German and English interface feedback for cooldown reset success and failure states. + +### Release validation + +- Covered manual cooldown recovery, shared session backoff, provider fallback attribution, queue release, active-download preservation, settings validation, preload exposure, and interface behavior with regression tests. +- Stabilized archive integration checks under concurrent test load and waited for asynchronous post-processing cleanup before asserting final folder removal. + ## [2.0.33] - 2026-08-13 ### Account and provider lifecycle diff --git a/package-lock.json b/package-lock.json index 77c551a..e0118fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.33", + "version": "2.0.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.33", + "version": "2.0.34", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 18937fd..fb00ac2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.33", + "version": "2.0.34", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index eaa49a3..995a305 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -576,7 +576,7 @@ export class AppController { return this.settings; } - public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { + public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { const liveSettings = this.manager.getSettings(); const nextSettings = normalizeSettings({ ...liveSettings, @@ -586,10 +586,16 @@ export class AppController { saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId }); - return this.settings; - } - - public async openRealDebridLoginWindow(): Promise { + return this.settings; + } + + public resetMegaDebridCooldowns(): { cleared: number } { + const cleared = this.manager.resetMegaDebridCooldowns(); + this.audit("INFO", "Mega-Debrid-Cooldowns manuell zurückgesetzt", { cleared }); + return { cleared }; + } + + public async openRealDebridLoginWindow(): Promise { this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); await this.realDebridWebFallback.openLoginWindow(); } diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 6834c33..d644d87 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -308,8 +308,10 @@ function getMegaDebridAbortMinRunMs(): number { return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT; } -const megaDebridEmptyResponseStreaks = new Map(); -export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10; +const megaDebridEmptyResponseStreaks = new Map(); +export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10; +const MEGA_DEBRID_WEB_SESSION_BACKOFF_MS = 20_000; +let megaDebridWebSessionBackoffUntil = 0; const megaDebridRotationState: Record<"api" | "web", { cursor: number; stickyCount: number }> = { api: { cursor: 0, stickyCount: 0 }, @@ -369,6 +371,14 @@ export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable Date.now() ? 1 : 0); + megaDebridAccountCooldowns.clear(); + megaDebridEmptyResponseStreaks.clear(); + megaDebridWebSessionBackoffUntil = 0; + return cleared; +} export function resetMegaDebridRuntimeStateForTests(): void { megaDebridAccountCooldowns.clear(); @@ -376,6 +386,21 @@ export function resetMegaDebridRuntimeStateForTests(): void { megaDebridRotationState.api = { cursor: 0, stickyCount: 0 }; megaDebridRotationState.web = { cursor: 0, stickyCount: 0 }; megaDebridInFlight.clear(); + megaDebridWebSessionBackoffUntil = 0; +} + +function getMegaDebridWebSessionBackoffRemaining(now = Date.now()): number { + const remaining = megaDebridWebSessionBackoffUntil - now; + if (remaining <= 0) { + megaDebridWebSessionBackoffUntil = 0; + return 0; + } + return remaining; +} + +function startMegaDebridWebSessionBackoff(now = Date.now()): number { + megaDebridWebSessionBackoffUntil = Math.max(megaDebridWebSessionBackoffUntil, now + MEGA_DEBRID_WEB_SESSION_BACKOFF_MS); + return getMegaDebridWebSessionBackoffRemaining(now); } export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number { @@ -2096,9 +2121,9 @@ class MegaDebridClient { if (!lastError) { lastError = "Mega-Web Antwort leer"; } - if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) { - break; - } + if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead|mega-web login ung(?:ü|u)ltig oder session blockiert/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) { + break; + } if (attempt < REQUEST_RETRIES) { await sleepWithSignal(retryDelay(attempt), signal); } @@ -2145,8 +2170,15 @@ class MegaDebridClient { } if (getAvailableMegaDebridAccounts(settings, mode).length === 0) { - throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)"); - } + throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)"); + } + + if (mode === "web") { + const sessionBackoffMs = getMegaDebridWebSessionBackoffRemaining(); + if (sessionBackoffMs > 0) { + throw new Error(`mega_debrid_session_backoff:${sessionBackoffMs}:Mega-Web Login vorübergehend blockiert`); + } + } const failures: string[] = []; let usableAccountSeen = false; @@ -2321,7 +2353,15 @@ class MegaDebridClient { throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); } const failure = MegaDebridClient.classifyAccountFailure(error, redactions); - traceConversionPhase({ + if (mode === "web" && /mega-web login ung(?:ü|u)ltig oder session blockiert/i.test(failure.message)) { + const sessionBackoffMs = startMegaDebridWebSessionBackoff(); + logAccountRotation("WARN", providerName, rotationLabel, "SESSION_BACKOFF", { + reason: failure.message, + cooldownSec: Math.ceil(sessionBackoffMs / 1000) + }); + throw new Error(`mega_debrid_session_backoff:${sessionBackoffMs}:${failure.message}`); + } + traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, @@ -2411,11 +2451,15 @@ class MegaDebridClient { ): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } { const errorText = sanitizeProviderErrorText(error, redactions); - if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) { - return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" }; - } - - if (/token.?error|please log.?in/i.test(errorText)) { + if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) { + return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" }; + } + + if (/mega-web login ung(?:ü|u)ltig oder session blockiert/i.test(errorText)) { + return { fatal: false, cooldownMs: 0, message: errorText, category: "temporary" }; + } + + if (/token.?error|please log.?in/i.test(errorText)) { return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" }; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 6602b8c..7e58cce 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -54,7 +54,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, clearMegaDebridAccountRuntimeStates, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountAttemptTimeoutMs, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, clearAllMegaDebridAccountRuntimeCooldowns, clearMegaDebridAccountRuntimeStates, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountAttemptTimeoutMs, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; @@ -709,7 +709,7 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() }; } -export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null { +export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null { const text = String(errorText || ""); const match = text.match(/mega_debrid_slow_link:(\d+)/i); if (!match) { @@ -774,9 +774,11 @@ function isHosterUnavailableError(errorText: string): boolean { return String(errorText || "").toLowerCase().includes("hosternotavailable"); } -function isTemporaryUnrestrictError(errorText: string): boolean { - const text = String(errorText || "").toLowerCase(); - return text.includes("server error") +function isTemporaryUnrestrictError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("mega-web login ungültig oder session blockiert") + || text.includes("mega-web login ungultig oder session blockiert") + || text.includes("server error") || text.includes("internal server error") || text.includes("notdebrid") || text.includes("unable to generate link") @@ -1739,6 +1741,21 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number { return Math.floor(jitter); } +export function parseMegaDebridSessionBackoff(errorText: string): { delayMs: number; detail: string } | null { + const match = String(errorText || "").match(/mega_debrid_session_backoff:(\d+):(.*)$/is); + if (!match) { + return null; + } + const raw = Number(match[1]); + if (!Number.isFinite(raw) || raw <= 0) { + return null; + } + return { + delayMs: Math.max(1000, Math.min(5 * 60 * 1000, raw)), + detail: String(match[2] || "").trim() + }; +} + function clearResumeRecoveryState(item: DownloadItem, includeHttp416Budget = true): void { delete item.resumeLinkRenewalFailures; delete item.resumeHardResetUsed; @@ -2614,8 +2631,59 @@ export class DownloadManager extends EventEmitter { } return released; } - - public getSettings(): AppSettings { + + public resetMegaDebridCooldowns(): number { + const cleared = clearAllMegaDebridAccountRuntimeCooldowns(); + let clearedProviderFailures = 0; + for (const key of [...this.providerFailures.keys()]) { + if (isMegaDebridProviderKey(key)) { + this.providerFailures.delete(key); + clearedProviderFailures += 1; + } + } + const activeItemIds = new Set([...this.activeTasks.values()].map((task) => task.itemId)); + const affectedPackageIds = new Set(); + let released = 0; + for (const item of Object.values(this.session.items)) { + if (activeItemIds.has(item.id) || (item.status !== "queued" && item.status !== "reconnect_wait")) { + continue; + } + const status = item.fullStatus || ""; + const expectedProvider = String(item.provider || this.getExpectedProviderForItem(item) || ""); + const isMegaWait = /Mega-Debrid (?:Cooldown|Session-Backoff|bis Tagesreset gesperrt)/i.test(status) + || (/Provider-Cooldown/i.test(status) && isMegaDebridProviderKey(expectedProvider)); + if (!isMegaWait) { + continue; + } + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.delete(item.id); + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.provider = null; + item.providerLabel = undefined; + item.providerAccountId = undefined; + item.providerAccountLabel = undefined; + item.updatedAt = nowMs(); + affectedPackageIds.add(item.packageId); + released += 1; + } + for (const packageId of affectedPackageIds) { + const pkg = this.session.packages[packageId]; + if (pkg) { + this.refreshPackageStatus(pkg); + } + } + logger.info(`Mega-Debrid-Cooldowns manuell zurückgesetzt: accounts=${cleared}, provider=${clearedProviderFailures}, queue=${released}`); + this.persistSoon(); + this.emitState(true); + if (this.session.running) { + void this.ensureScheduler().catch((error) => logger.error(`Scheduler nach Cooldown-Reset fehlgeschlagen: ${compactErrorText(error)}`)); + } + return cleared; + } + + public getSettings(): AppSettings { return this.settings; } @@ -9542,7 +9610,8 @@ export class DownloadManager extends EventEmitter { if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } - const cooldownProvider = this.getProviderFailureKeyForItem(item); + active.validationProvider = null; + const cooldownProvider = this.getProviderFailureKeyForItem(item); const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider); let preferredLeadProvider: DebridProvider | null = null; if (cooldownMs > 0) { @@ -9634,21 +9703,22 @@ export class DownloadManager extends EventEmitter { packageId: item.packageId } ); - } catch (unrestrictError) { - if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { - this.recordProviderFailure(cooldownProvider); + } catch (unrestrictError) { + const failedProvider = this.getProviderFailureKeyForItem(item, active.validationProvider || cooldownProvider); + if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { + this.recordProviderFailure(failedProvider); throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); - } - const errText = compactErrorText(unrestrictError); - if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { - this.recordProviderFailure(cooldownProvider); - if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { - const busyCooldownMs = isTemporaryUnrestrictError(errText) - ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) - : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); - this.applyProviderBusyBackoff(cooldownProvider, busyCooldownMs); - } - } + } + const errText = compactErrorText(unrestrictError); + if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { + this.recordProviderFailure(failedProvider); + if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { + const busyCooldownMs = isTemporaryUnrestrictError(errText) + ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) + : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); + this.applyProviderBusyBackoff(failedProvider, busyCooldownMs); + } + } throw unrestrictError; } if (active.abortController.signal.aborted) { @@ -10287,8 +10357,23 @@ export class DownloadManager extends EventEmitter { return; } - const megaRawError = error instanceof Error ? String(error.message || "") : String(error || ""); - const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError); + const megaRawError = error instanceof Error ? String(error.message || "") : String(error || ""); + const megaSessionBackoff = parseMegaDebridSessionBackoff(megaRawError); + if (megaSessionBackoff && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + this.queueRetry( + item, + active, + megaSessionBackoff.delayMs, + `Mega-Debrid Session-Backoff, neuer Versuch in ${Math.ceil(megaSessionBackoff.delayMs / 1000)}s` + ); + item.lastError = megaSessionBackoff.detail || errorText; + this.persistSoon(); + this.emitState(); + return; + } + const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError); if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) { active.unrestrictRetries += 1; item.retries += 1; @@ -10393,7 +10478,7 @@ export class DownloadManager extends EventEmitter { active.unrestrictRetries += 1; item.retries += 1; - const failureProvider = this.getProviderFailureKeyForItem(item); + const failureProvider = this.getProviderFailureKeyForItem(item, active.validationProvider || item.provider); const isDebridLinkError = /debrid-link|debrid_link/i.test(errorText) || failureProvider === "debridlink"; if (!isDebridLinkError) { this.recordProviderFailure(failureProvider); diff --git a/src/main/main.ts b/src/main/main.ts index ee43f17..e9a218d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -411,13 +411,14 @@ function registerIpcHandlers(): void { } return createRendererSettings(controller.resetProviderDailyUsage(validatedProvider)); }); - handleTrusted(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { + handleTrusted(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { const validatedKeyId = validateString(keyId, "keyId").trim(); if (!validatedKeyId) { throw new Error("keyId ist ungültig"); } return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId)); }); + handleTrusted(IPC_CHANNELS.RESET_MEGA_DEBRID_COOLDOWNS, () => controller.resetMegaDebridCooldowns()); handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts index 4aa517a..f027fcd 100644 --- a/src/main/renderer-settings.ts +++ b/src/main/renderer-settings.ts @@ -64,6 +64,9 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti const input = value as Record; const output: Record = {}; for (const [key, entry] of Object.entries(input)) { + if (entry === undefined) { + continue; + } if (DERIVED_KEYS.has(key)) { continue; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index ab99261..2a475be 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -39,6 +39,7 @@ const api: ElectronApi = { updateSettings: (settings: RendererSettingsUpdate): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), + resetMegaDebridCooldowns: (): Promise<{ cleared: number }> => ipcRenderer.invoke(IPC_CHANNELS.RESET_MEGA_DEBRID_COOLDOWNS), createAccount: (command: AccountCreateCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.CREATE_ACCOUNT, command), replaceAccount: (command: AccountReplaceCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_ACCOUNT, command), updateAccountSecret: (command: AccountUpdateSecretCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, command), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7b56b3a..71d10a3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2914,7 +2914,7 @@ export function App(): ReactElement { }); }; - const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise => { + const onResetDebridLinkApiKeyDailyUsage = async (entry: ConfiguredAccountEntry, keyId: string, keyLabel: string): Promise => { await performQuickAction(async () => { const result = await window.rd.resetDebridLinkApiKeyDailyUsage(keyId); syncLiveProviderUsageSettings(result); @@ -3014,6 +3014,16 @@ export function App(): ReactElement { }); }; + const onResetMegaDebridCooldowns = async (): Promise => { + await performQuickAction(async () => { + const result = await window.rd.resetMegaDebridCooldowns(); + await reconcileAuthoritativeSnapshot(); + showToast(result.cleared > 0 ? "Mega-Debrid-Cooldowns zurückgesetzt" : "Keine aktiven Mega-Debrid-Cooldowns.", 2400); + }, (error) => { + showToast(`Cooldown-Reset fehlgeschlagen: ${String(error)}`, 3200); + }); + }; + const getAccountToggleTarget = (row: AccountTableRow): AccountToggleTarget | null => { if (row.toggleKind === "mega" && row.accountId) { return { @@ -5155,7 +5165,8 @@ export function App(): ReactElement { : hosterId; if (!resolvedHosterId || settingsDraft.hosterRouting?.[resolvedHosterId] || !configuredProviders[0]) return; setHosterRouting({ ...(settingsDraft.hosterRouting || {}), [resolvedHosterId]: configuredProviders[0] }); - } + }, + onResetMegaDebridCooldowns: () => { void onResetMegaDebridCooldowns(); } }; const settingsFormActions: SettingsViewActions["form"] = { diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index fffc1ae..69d37a2 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -49,7 +49,7 @@ const pairs = [ ["Fertige Downloads und Konflikte", "Completed downloads and conflicts"], ["Fertige Downloads aus der Liste", "Completed downloads in the list"], ["Bei gleichnamigen Dateien", "When files have the same name"], ["Hinzufügen", "Add"], ["Aktualisieren", "Refresh"], ["+ Hinzufügen", "+ Add"], ["− Entfernen", "− Remove"], ["↻ Aktualisieren", "↻ Refresh"], ["Provider-Reihenfolge", "Provider order"], ["Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.", "Choose the order in which available providers are used."], ["Automatischer Fallback", "Automatic fallback"], ["Zugangsdaten lokal speichern", "Store credentials locally"], ["Hoster-Routing", "Hoster routing"], ["Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.", "Custom assignments override the default order for each hoster."], - ["Rotations-Verlauf", "Rotation history"], ["Sammlung", "Collection"], ["URL oder Rohzeile", "URL or raw line"], ["Zeile", "Line"], ["Paket / Datei", "Package / file"], ["Größe", "Size"], ["Gestartet", "Started"], ["Beendet", "Finished"], + ["Rotations-Verlauf", "Rotation history"], ["Cooldowns zurücksetzen", "Reset cooldowns"], ["Keine aktiven Mega-Debrid-Cooldowns.", "No active Mega-Debrid cooldowns."], ["Mega-Debrid-Cooldowns zurückgesetzt", "Mega-Debrid cooldowns reset"], ["Cooldown-Reset fehlgeschlagen: ", "Cooldown reset failed: "], ["Sammlung", "Collection"], ["URL oder Rohzeile", "URL or raw line"], ["Zeile", "Line"], ["Paket / Datei", "Package / file"], ["Größe", "Size"], ["Gestartet", "Started"], ["Beendet", "Finished"], ["Alle Einträge", "All entries"], ["Heute", "Today"], ["Letzte 7 Tage", "Last 7 days"], ["Älter", "Older"], ["Gelöscht", "Deleted"], ["Fehlgeschlagen", "Failed"], ["Dauer", "Duration"], ["Durchschnitt", "Average"], ["Zielordner", "Destination folder"], ["Verlaufsfilter", "History filters"], ["Verlauf leeren", "Clear history"], ["Verlaufsaktionen", "History actions"], ["Einträge", "Entries"], ["Erneut hinzufügen", "Add again"], ["Im Ordner zeigen", "Show in folder"], ["Auswahl löschen", "Clear selection"], ["Gesamtverlauf löschen", "Delete all history"], ["Verlauf durchsuchen", "Search history"], ["Name, Pfad, Hoster oder Provider", "Name, path, hoster or provider"], diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index c48fba4..b432b16 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -86,7 +86,8 @@ export interface AccountWorkspaceActions { onToggleRememberCredentials?: (enabled: boolean) => void; onRoutingProviderChange?: (hosterId: string, provider: string) => void; onRoutingRemove?: (hosterId: string) => void; - onRoutingAdd?: (hosterId: string) => void; + onRoutingAdd?: (hosterId: string) => void; + onResetMegaDebridCooldowns?: () => void; } export interface AccountWorkspaceProps { @@ -412,9 +413,17 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement { )} - {model.rules.rotationEvents ? ( -
-

Rotations-Verlauf

+ {model.rules.rotationEvents ? ( +
+
+

Rotations-Verlauf

+ +
{model.rules.rotationEvents.length === 0 ? ( Noch keine Rotations-Ereignisse. ) : model.rules.rotationEvents.map((event) => ( diff --git a/src/renderer/views/settings/settings.css b/src/renderer/views/settings/settings.css index 328d736..b8f8027 100644 --- a/src/renderer/views/settings/settings.css +++ b/src/renderer/views/settings/settings.css @@ -782,6 +782,13 @@ border-bottom: 1px solid var(--ui-border); } +.settings-rule-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + .settings-provider-order, .settings-routing-list { display: grid; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index ba4cd6e..a881dc2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -10,8 +10,9 @@ export const IPC_CHANNELS = { REPLACE_ACCOUNT: "app:replace-account", UPDATE_ACCOUNT_SECRET: "app:update-account-secret", DELETE_ACCOUNT: "app:delete-account", - RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage", - RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", + RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage", + RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", + RESET_MEGA_DEBRID_COOLDOWNS: "app:reset-mega-debrid-cooldowns", ADD_LINKS: "queue:add-links", ADD_CONTAINERS: "queue:add-containers", GET_START_CONFLICTS: "queue:get-start-conflicts", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 7223dc9..34a88ea 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -39,6 +39,7 @@ export interface ElectronApi { updateSettings: (settings: RendererSettingsUpdate) => Promise; resetProviderDailyUsage: (provider: DebridProvider) => Promise; resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; + resetMegaDebridCooldowns: () => Promise<{ cleared: number }>; createAccount: (command: AccountCreateCommand) => Promise; replaceAccount: (command: AccountReplaceCommand) => Promise; updateAccountSecret: (command: AccountUpdateSecretCommand) => Promise; diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index 51f4092..e7138b3 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -72,4 +72,13 @@ describe("account preload contract", () => { "https://rapidgator.net/file/example" ); }); + + it("resets Mega-Debrid runtime cooldowns through a dedicated channel", async () => { + electron.invoke.mockResolvedValueOnce({ cleared: 3 }); + + const result = await (electron.api as unknown as { resetMegaDebridCooldowns: () => Promise<{ cleared: number }> }).resetMegaDebridCooldowns(); + + expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.RESET_MEGA_DEBRID_COOLDOWNS); + expect(result).toEqual({ cleared: 3 }); + }); }); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index aaadead..35f718d 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -3,7 +3,8 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; -import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; +import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; +import * as debridRuntime from "../src/main/debrid"; import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeDebridLinkRuntimeCooldownForTests, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; const originalFetch = globalThis.fetch; @@ -2865,6 +2866,62 @@ describe("debrid service", () => { expect(genuineEmpty.limitSignal).toBe(true); }); + it("does not cool an account for the ambiguous Mega-Web login or session-blocked response", () => { + const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Web Login ungültig oder Session blockiert")); + + expect(result.fatal).toBe(false); + expect(result.category).toBe("temporary"); + expect(result.cooldownMs).toBe(0); + }); + + it("backs off the shared Mega-Web session after one ambiguous login failure without cooling every account", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaDebridWebCredentials: "web-user-1:web-pass-1\nweb-user-2:web-pass-2\nweb-user-3:web-pass-3", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + providerOrder: ["megadebrid-web" as const], + providerPrimary: "megadebrid-web" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const megaWeb = vi.fn(async () => { + throw new Error("Mega-Web Login ungültig oder Session blockiert"); + }); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + + const firstError = await service.unrestrictLink("https://rapidgator.net/file/shared-session-1.rar.html").then(() => null, (error: unknown) => error); + const secondError = await service.unrestrictLink("https://rapidgator.net/file/shared-session-2.rar.html").then(() => null, (error: unknown) => error); + + expect(String(firstError)).toMatch(/mega_debrid_session_backoff:\d+:/i); + expect(String(secondError)).toMatch(/mega_debrid_session_backoff:\d+:/i); + expect(megaWeb).toHaveBeenCalledTimes(1); + for (const login of ["web-user-1", "web-user-2", "web-user-3"]) { + expect(getMegaDebridAccountCooldownState(`${getMegaDebridAccountId(login)}:web`)).toBeNull(); + } + + expect((debridRuntime as unknown as { clearAllMegaDebridAccountRuntimeCooldowns: () => number }).clearAllMegaDebridAccountRuntimeCooldowns()).toBe(1); + await service.unrestrictLink("https://rapidgator.net/file/shared-session-3.rar.html").catch(() => undefined); + expect(megaWeb).toHaveBeenCalledTimes(2); + }); + + it("clears every Mega-Debrid account cooldown without resetting account configuration", () => { + const apiKey = `${getMegaDebridAccountId("api-user")}:api`; + const webKey = `${getMegaDebridAccountId("web-user")}:web`; + primeMegaDebridRuntimeCooldownForTests(apiKey, 120_000); + primeMegaDebridUntilRestartForTests(webKey); + + const cleared = (debridRuntime as unknown as { clearAllMegaDebridAccountRuntimeCooldowns: () => number }).clearAllMegaDebridAccountRuntimeCooldowns(); + + expect(cleared).toBe(2); + expect(getMegaDebridAccountCooldownState(apiKey)).toBeNull(); + expect(getMegaDebridAccountCooldownState(webKey)).toBeNull(); + }); + it("sanitizes provider-supplied account failures before they leave Mega-Debrid rotation", async () => { const login = "private-user@example.test"; const password = "provider-password-secret"; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 3b65d6a..c2e638f 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1283,6 +1283,136 @@ describe("download manager", () => { expect(failures.has("realdebrid:rapidgator.net")).toBe(true); }); + it("clears Mega-Debrid cooldowns and releases parked queue items without touching other providers", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-manual-cooldown-reset-")); + tempDirs.push(root); + const accountId = getMegaDebridAccountId("web-user"); + const settings = { + ...defaultSettings(), + megaDebridWebCredentials: "web-user:web-pass", + megaDebridWebEnabled: true + }; + const activeController = new AbortController(); + const invalidateMegaSession = vi.fn(() => activeController.abort("session_invalidated")); + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession }); + manager.addPackages([{ name: "cooldown-reset", links: [ + "https://rapidgator.net/file/cooldown-reset", + "https://rapidgator.net/file/active-resolution" + ] }]); + const session = (manager as any).session; + const [item, activeItem] = Object.values(session.items) as any[]; + item.status = "queued"; + item.fullStatus = "Mega-Debrid Cooldown, neuer Versuch in 30s"; + item.lastError = "Mega-Web Login ungültig oder Session blockiert"; + item.provider = "megadebrid-web"; + (manager as any).retryAfterByItem.set(item.id, Date.now() + 30_000); + (manager as any).retryStateByItem.set(item.id, { key: "mega", attempts: 1 }); + activeItem.status = "validating"; + activeItem.provider = "megadebrid-web"; + (manager as any).activeTasks.set(activeItem.id, { + itemId: activeItem.id, + packageId: activeItem.packageId, + abortController: activeController, + abortReason: "none", + resumable: true, + nonResumableCounted: false, + validationProvider: "megadebrid-web" + }); + const failures = (manager as any).providerFailures as Map; + failures.set("megadebrid-web", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + primeMegaDebridRuntimeCooldownForTests(`${accountId}:web`, 120_000); + + const cleared = (manager as unknown as { resetMegaDebridCooldowns: () => number }).resetMegaDebridCooldowns(); + + expect(cleared).toBe(1); + expect(getMegaDebridAccountCooldownState(`${accountId}:web`)).toBeNull(); + expect((manager as any).retryAfterByItem.has(item.id)).toBe(false); + expect((manager as any).retryStateByItem.has(item.id)).toBe(false); + expect(item.status).toBe("queued"); + expect(item.fullStatus).toBe("Wartet"); + expect(item.lastError).toBe(""); + expect(failures.has("megadebrid-web")).toBe(false); + expect(failures.has("realdebrid")).toBe(true); + expect(invalidateMegaSession).not.toHaveBeenCalled(); + expect(activeController.signal.aborted).toBe(false); + }); + + it("applies a shared Mega-Web backoff after an ambiguous login failure without starting the next queued item", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-shared-login-backoff-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + megaDebridWebCredentials: "web-user:web-pass", + megaDebridWebEnabled: true, + providerOrder: ["megadebrid-web" as const], + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + const attempts = vi.fn(async ( + _link: string, + _signal?: AbortSignal, + _settingsSnapshot?: AppSettings, + _preferredLeadProvider?: DebridProvider | null, + onProviderAttempt?: (provider: DebridProvider) => void + ) => { + onProviderAttempt?.("megadebrid-web"); + throw new Error("Mega-Web Login ungültig oder Session blockiert"); + }); + (manager as any).debridService.unrestrictLink = attempts; + manager.addPackages([{ name: "shared-login-backoff", links: [ + "https://rapidgator.net/file/shared-login-backoff-1", + "https://rapidgator.net/file/shared-login-backoff-2" + ] }]); + + await manager.start(); + await waitFor(() => attempts.mock.calls.length >= 1, 5_000); + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(attempts).toHaveBeenCalledTimes(1); + expect((manager as any).getProviderCooldownRemaining("megadebrid-web")).toBeGreaterThan(10_000); + manager.stop(); + }); + + it("assigns a fallback Mega-Web session failure to the provider that actually failed", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-fallback-failure-key-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + token: "real-debrid-token", + megaDebridWebCredentials: "web-user:web-pass", + megaDebridWebEnabled: true, + providerOrder: ["realdebrid" as const, "megadebrid-web" as const], + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + (manager as any).debridService.unrestrictLink = async ( + _link: string, + _signal?: AbortSignal, + _settingsSnapshot?: AppSettings, + _preferredLeadProvider?: DebridProvider | null, + onProviderAttempt?: (provider: DebridProvider) => void + ) => { + onProviderAttempt?.("realdebrid"); + onProviderAttempt?.("megadebrid-web"); + throw new Error("Mega-Web Login ungültig oder Session blockiert"); + }; + manager.addPackages([{ name: "fallback-failure-key", links: ["https://rapidgator.net/file/fallback-failure-key"] }]); + + await manager.start(); + await waitFor(() => (manager as any).getProviderCooldownRemaining("megadebrid-web") > 0, 5_000); + + expect((manager as any).getProviderCooldownRemaining("megadebrid-web")).toBeGreaterThan(10_000); + expect((manager as any).getProviderCooldownRemaining("realdebrid")).toBe(0); + manager.stop(); + }); + it("refreshes an active Debrid-Link key pool without restarting the application", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debrid-link-live-pool-refresh-")); tempDirs.push(root); @@ -12181,7 +12311,7 @@ describe("download manager", () => { ); const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv"); - await waitFor(() => fs.existsSync(flattenedPath), 12000); + await waitFor(() => fs.existsSync(flattenedPath) && !fs.existsSync(extractDir), 12000); await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000); expect(fs.existsSync(flattenedPath)).toBe(true); diff --git a/tests/extractor-jvm.test.ts b/tests/extractor-jvm.test.ts index 334b95c..daf761b 100644 --- a/tests/extractor-jvm.test.ts +++ b/tests/extractor-jvm.test.ts @@ -63,9 +63,9 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b expect(result.extracted).toBe(1); expect(result.failed).toBe(0); expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); - }); - - it("emits progress callbacks with archiveName and percent", async () => { + }, 15000); + + it("emits progress callbacks with archiveName and percent", async () => { process.env.RD_EXTRACT_BACKEND = "jvm"; const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-")); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 3dec775..7d50e29 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -104,9 +104,9 @@ describe("extractor", () => { expect(fs.existsSync(validZipPath)).toBe(false); expect(fs.existsSync(invalidZipPath)).toBe(true); expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); - }); - - it("collects companion rar parts for cleanup", () => { + }, 15000); + + it("collects companion rar parts for cleanup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); diff --git a/tests/renderer-settings.test.ts b/tests/renderer-settings.test.ts new file mode 100644 index 0000000..894faa2 --- /dev/null +++ b/tests/renderer-settings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { validateRendererSettingsUpdate } from "../src/main/renderer-settings"; + +describe("renderer settings validation", () => { + it("ignores undefined optional fields from a stale renderer draft", () => { + const result = validateRendererSettingsUpdate({ + columnOrderVersion: undefined, + megaDebridApiEnabled: false, + megaDebridWebEnabled: false, + debridLinkDisabledKeyIds: [] + }, defaultSettings()); + + expect(result).toEqual({ + megaDebridApiEnabled: false, + megaDebridWebEnabled: false, + debridLinkDisabledKeyIds: [] + }); + }); +}); diff --git a/tests/reset-controller.test.ts b/tests/reset-controller.test.ts index b4d61c4..ab3634f 100644 --- a/tests/reset-controller.test.ts +++ b/tests/reset-controller.test.ts @@ -14,6 +14,20 @@ vi.mock("electron", () => ({ })); describe("reset controller boundary", () => { + it("delegates the Mega-Debrid cooldown reset and audits the cleared count", () => { + const controller = Object.create(AppController.prototype) as { + manager: { resetMegaDebridCooldowns: ReturnType }; + audit: ReturnType; + resetMegaDebridCooldowns: () => { cleared: number }; + }; + controller.manager = { resetMegaDebridCooldowns: vi.fn(() => 3) }; + controller.audit = vi.fn(); + + expect(controller.resetMegaDebridCooldowns()).toEqual({ cleared: 3 }); + expect(controller.manager.resetMegaDebridCooldowns).toHaveBeenCalledTimes(1); + expect(controller.audit).toHaveBeenCalledWith("INFO", "Mega-Debrid-Cooldowns manuell zurückgesetzt", { cleared: 3 }); + }); + it("audits reset completion only after the manager operation succeeds", async () => { const packagePromise = Promise.resolve(); const itemPromise = Promise.resolve(); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 87f1b4b..dc8fc8a 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -771,6 +771,26 @@ describe("account workspace", () => { expect(html).toContain("Automatischer Fallback"); }); + it("offers a visible action that resets every Mega-Debrid cooldown", () => { + const calls: string[] = []; + const tree = AccountWorkspace({ + model: { + ...workspaceModel(), + activePanel: "rules", + rules: { + ...workspaceModel().rules, + rotationEvents: [{ id: "rotation-1", title: "Mega-Debrid Web · Account 1/3", detail: "übersprungen (Cooldown aktiv)" }] + } + }, + actions: workspaceActions({ onResetMegaDebridCooldowns: () => calls.push("reset") } as Partial) + }); + const button = findElement(tree, (element) => element.type === "button" && element.props.children === "Cooldowns zurücksetzen"); + + button.props.onClick(); + + expect(calls).toEqual(["reset"]); + }); + it("keeps add and edit dialogs separate and every secret field protected", () => { const options = accountOptions(); const addHtml = renderToStaticMarkup( diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index aa277c1..29c2b66 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -35,6 +35,7 @@ export function createVisualElectronApi( fixture.snapshot.settings.debridLinkApiKeyDailyUsageBytes[keyId] = 0; return clone(fixture.snapshot.settings); }, + resetMegaDebridCooldowns: async () => ({ cleared: 0 }), createAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), replaceAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), updateAccountSecret: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),