release: publish v2.0.34 account recovery fixes

Add a manual Mega-Debrid cooldown reset that preserves active downloads while releasing affected queued work. Replace false per-account cooling for ambiguous Mega-Web session failures with one shared session backoff, attribute fallback failures to the provider actually attempted, and accept omitted optional renderer settings during provider changes. Expand regression coverage and stabilize archive integration checks under concurrent validation load.
This commit is contained in:
Sucukdeluxe
2026-08-13 22:06:11 +02:00
parent 4972858c9d
commit 4fe951de78
24 changed files with 503 additions and 64 deletions
+19
View File
@@ -2,6 +2,25 @@
All notable changes to Multi-Debrid Downloader are documented in this file. 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 ## [2.0.33] - 2026-08-13
### Account and provider lifecycle ### Account and provider lifecycle
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.33", "version": "2.0.34",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.33", "version": "2.0.34",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.33", "version": "2.0.34",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+6
View File
@@ -589,6 +589,12 @@ export class AppController {
return this.settings; 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<void> { public async openRealDebridLoginWindow(): Promise<void> {
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
await this.realDebridWebFallback.openLoginWindow(); await this.realDebridWebFallback.openLoginWindow();
+45 -1
View File
@@ -310,6 +310,8 @@ function getMegaDebridAbortMinRunMs(): number {
const megaDebridEmptyResponseStreaks = new Map<string, number>(); const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10; 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 }> = { const megaDebridRotationState: Record<"api" | "web", { cursor: number; stickyCount: number }> = {
api: { cursor: 0, stickyCount: 0 }, api: { cursor: 0, stickyCount: 0 },
@@ -370,12 +372,35 @@ export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable<string
} }
} }
export function clearAllMegaDebridAccountRuntimeCooldowns(): number {
const cleared = megaDebridAccountCooldowns.size + (megaDebridWebSessionBackoffUntil > Date.now() ? 1 : 0);
megaDebridAccountCooldowns.clear();
megaDebridEmptyResponseStreaks.clear();
megaDebridWebSessionBackoffUntil = 0;
return cleared;
}
export function resetMegaDebridRuntimeStateForTests(): void { export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear(); megaDebridAccountCooldowns.clear();
megaDebridEmptyResponseStreaks.clear(); megaDebridEmptyResponseStreaks.clear();
megaDebridRotationState.api = { cursor: 0, stickyCount: 0 }; megaDebridRotationState.api = { cursor: 0, stickyCount: 0 };
megaDebridRotationState.web = { cursor: 0, stickyCount: 0 }; megaDebridRotationState.web = { cursor: 0, stickyCount: 0 };
megaDebridInFlight.clear(); 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 { export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
@@ -2096,7 +2121,7 @@ class MegaDebridClient {
if (!lastError) { if (!lastError) {
lastError = "Mega-Web Antwort leer"; 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)) { 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; break;
} }
if (attempt < REQUEST_RETRIES) { if (attempt < REQUEST_RETRIES) {
@@ -2148,6 +2173,13 @@ class MegaDebridClient {
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[] = []; const failures: string[] = [];
let usableAccountSeen = false; let usableAccountSeen = false;
const cooldownFailures: string[] = []; const cooldownFailures: string[] = [];
@@ -2321,6 +2353,14 @@ class MegaDebridClient {
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
} }
const failure = MegaDebridClient.classifyAccountFailure(error, redactions); const failure = MegaDebridClient.classifyAccountFailure(error, redactions);
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({ traceConversionPhase({
phase: "mega-account", phase: "mega-account",
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
@@ -2415,6 +2455,10 @@ class MegaDebridClient {
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" }; 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)) { if (/token.?error|please log.?in/i.test(errorText)) {
return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" }; return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" };
} }
+91 -6
View File
@@ -54,7 +54,7 @@ function releaseTlsSkip(): void {
} }
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; 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 { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; import { classifyDiskError } from "./fs-error";
@@ -776,7 +776,9 @@ function isHosterUnavailableError(errorText: string): boolean {
function isTemporaryUnrestrictError(errorText: string): boolean { function isTemporaryUnrestrictError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase(); const text = String(errorText || "").toLowerCase();
return text.includes("server error") 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("internal server error")
|| text.includes("notdebrid") || text.includes("notdebrid")
|| text.includes("unable to generate link") || text.includes("unable to generate link")
@@ -1739,6 +1741,21 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
return Math.floor(jitter); 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 { function clearResumeRecoveryState(item: DownloadItem, includeHttp416Budget = true): void {
delete item.resumeLinkRenewalFailures; delete item.resumeLinkRenewalFailures;
delete item.resumeHardResetUsed; delete item.resumeHardResetUsed;
@@ -2615,6 +2632,57 @@ export class DownloadManager extends EventEmitter {
return released; return released;
} }
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<string>();
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 { public getSettings(): AppSettings {
return this.settings; return this.settings;
} }
@@ -9542,6 +9610,7 @@ export class DownloadManager extends EventEmitter {
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`); throw new Error(`aborted:${active.abortReason}`);
} }
active.validationProvider = null;
const cooldownProvider = this.getProviderFailureKeyForItem(item); const cooldownProvider = this.getProviderFailureKeyForItem(item);
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider); const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
let preferredLeadProvider: DebridProvider | null = null; let preferredLeadProvider: DebridProvider | null = null;
@@ -9635,18 +9704,19 @@ export class DownloadManager extends EventEmitter {
} }
); );
} catch (unrestrictError) { } catch (unrestrictError) {
const failedProvider = this.getProviderFailureKeyForItem(item, active.validationProvider || cooldownProvider);
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
this.recordProviderFailure(cooldownProvider); this.recordProviderFailure(failedProvider);
throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`);
} }
const errText = compactErrorText(unrestrictError); const errText = compactErrorText(unrestrictError);
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
this.recordProviderFailure(cooldownProvider); this.recordProviderFailure(failedProvider);
if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) {
const busyCooldownMs = isTemporaryUnrestrictError(errText) const busyCooldownMs = isTemporaryUnrestrictError(errText)
? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000)
: Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000);
this.applyProviderBusyBackoff(cooldownProvider, busyCooldownMs); this.applyProviderBusyBackoff(failedProvider, busyCooldownMs);
} }
} }
throw unrestrictError; throw unrestrictError;
@@ -10288,6 +10358,21 @@ export class DownloadManager extends EventEmitter {
} }
const megaRawError = error instanceof Error ? String(error.message || "") : String(error || ""); 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); const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError);
if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) { if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1; active.unrestrictRetries += 1;
@@ -10393,7 +10478,7 @@ export class DownloadManager extends EventEmitter {
active.unrestrictRetries += 1; active.unrestrictRetries += 1;
item.retries += 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"; const isDebridLinkError = /debrid-link|debrid_link/i.test(errorText) || failureProvider === "debridlink";
if (!isDebridLinkError) { if (!isDebridLinkError) {
this.recordProviderFailure(failureProvider); this.recordProviderFailure(failureProvider);
+1
View File
@@ -418,6 +418,7 @@ function registerIpcHandlers(): void {
} }
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId)); return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
}); });
handleTrusted(IPC_CHANNELS.RESET_MEGA_DEBRID_COOLDOWNS, () => controller.resetMegaDebridCooldowns());
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand); const command = validateAccountCommand(rawCommand);
+3
View File
@@ -64,6 +64,9 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti
const input = value as Record<string, unknown>; const input = value as Record<string, unknown>;
const output: Record<string, unknown> = {}; const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(input)) { for (const [key, entry] of Object.entries(input)) {
if (entry === undefined) {
continue;
}
if (DERIVED_KEYS.has(key)) { if (DERIVED_KEYS.has(key)) {
continue; continue;
} }
+1
View File
@@ -39,6 +39,7 @@ const api: ElectronApi = {
updateSettings: (settings: RendererSettingsUpdate): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), updateSettings: (settings: RendererSettingsUpdate): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
resetProviderDailyUsage: (provider: DebridProvider): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), resetProviderDailyUsage: (provider: DebridProvider): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<RendererSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<RendererSettings> => 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<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.CREATE_ACCOUNT, command), createAccount: (command: AccountCreateCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.CREATE_ACCOUNT, command),
replaceAccount: (command: AccountReplaceCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_ACCOUNT, command), replaceAccount: (command: AccountReplaceCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_ACCOUNT, command),
updateAccountSecret: (command: AccountUpdateSecretCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, command), updateAccountSecret: (command: AccountUpdateSecretCommand): Promise<AccountCommandResult> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, command),
+12 -1
View File
@@ -3014,6 +3014,16 @@ export function App(): ReactElement {
}); });
}; };
const onResetMegaDebridCooldowns = async (): Promise<void> => {
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 => { const getAccountToggleTarget = (row: AccountTableRow): AccountToggleTarget | null => {
if (row.toggleKind === "mega" && row.accountId) { if (row.toggleKind === "mega" && row.accountId) {
return { return {
@@ -5155,7 +5165,8 @@ export function App(): ReactElement {
: hosterId; : hosterId;
if (!resolvedHosterId || settingsDraft.hosterRouting?.[resolvedHosterId] || !configuredProviders[0]) return; if (!resolvedHosterId || settingsDraft.hosterRouting?.[resolvedHosterId] || !configuredProviders[0]) return;
setHosterRouting({ ...(settingsDraft.hosterRouting || {}), [resolvedHosterId]: configuredProviders[0] }); setHosterRouting({ ...(settingsDraft.hosterRouting || {}), [resolvedHosterId]: configuredProviders[0] });
} },
onResetMegaDebridCooldowns: () => { void onResetMegaDebridCooldowns(); }
}; };
const settingsFormActions: SettingsViewActions["form"] = { const settingsFormActions: SettingsViewActions["form"] = {
+1 -1
View File
@@ -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"], ["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."], ["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."], ["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"], ["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"], ["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"], ["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"],
@@ -87,6 +87,7 @@ export interface AccountWorkspaceActions {
onRoutingProviderChange?: (hosterId: string, provider: string) => void; onRoutingProviderChange?: (hosterId: string, provider: string) => void;
onRoutingRemove?: (hosterId: string) => void; onRoutingRemove?: (hosterId: string) => void;
onRoutingAdd?: (hosterId: string) => void; onRoutingAdd?: (hosterId: string) => void;
onResetMegaDebridCooldowns?: () => void;
} }
export interface AccountWorkspaceProps { export interface AccountWorkspaceProps {
@@ -414,7 +415,15 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
</section> </section>
{model.rules.rotationEvents ? ( {model.rules.rotationEvents ? (
<section className="settings-rule-section"> <section className="settings-rule-section">
<h3>Rotations-Verlauf</h3> <div className="settings-rule-section-heading">
<h3>Rotations-Verlauf</h3>
<button
className="settings-button settings-button-secondary"
disabled={model.busy || !actions.onResetMegaDebridCooldowns}
onClick={actions.onResetMegaDebridCooldowns}
type="button"
>Cooldowns zurücksetzen</button>
</div>
{model.rules.rotationEvents.length === 0 ? ( {model.rules.rotationEvents.length === 0 ? (
<span className="settings-rule-empty">Noch keine Rotations-Ereignisse.</span> <span className="settings-rule-empty">Noch keine Rotations-Ereignisse.</span>
) : model.rules.rotationEvents.map((event) => ( ) : model.rules.rotationEvents.map((event) => (
+7
View File
@@ -782,6 +782,13 @@
border-bottom: 1px solid var(--ui-border); 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-provider-order,
.settings-routing-list { .settings-routing-list {
display: grid; display: grid;
+1
View File
@@ -12,6 +12,7 @@ export const IPC_CHANNELS = {
DELETE_ACCOUNT: "app:delete-account", DELETE_ACCOUNT: "app:delete-account",
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-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_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_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers", ADD_CONTAINERS: "queue:add-containers",
GET_START_CONFLICTS: "queue:get-start-conflicts", GET_START_CONFLICTS: "queue:get-start-conflicts",
+1
View File
@@ -39,6 +39,7 @@ export interface ElectronApi {
updateSettings: (settings: RendererSettingsUpdate) => Promise<RendererSettings>; updateSettings: (settings: RendererSettingsUpdate) => Promise<RendererSettings>;
resetProviderDailyUsage: (provider: DebridProvider) => Promise<RendererSettings>; resetProviderDailyUsage: (provider: DebridProvider) => Promise<RendererSettings>;
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<RendererSettings>; resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<RendererSettings>;
resetMegaDebridCooldowns: () => Promise<{ cleared: number }>;
createAccount: (command: AccountCreateCommand) => Promise<AccountCommandResult>; createAccount: (command: AccountCreateCommand) => Promise<AccountCommandResult>;
replaceAccount: (command: AccountReplaceCommand) => Promise<AccountCommandResult>; replaceAccount: (command: AccountReplaceCommand) => Promise<AccountCommandResult>;
updateAccountSecret: (command: AccountUpdateSecretCommand) => Promise<AccountCommandResult>; updateAccountSecret: (command: AccountUpdateSecretCommand) => Promise<AccountCommandResult>;
+9
View File
@@ -72,4 +72,13 @@ describe("account preload contract", () => {
"https://rapidgator.net/file/example" "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 });
});
}); });
+57
View File
@@ -4,6 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; 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"; 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; const originalFetch = globalThis.fetch;
@@ -2865,6 +2866,62 @@ describe("debrid service", () => {
expect(genuineEmpty.limitSignal).toBe(true); 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 () => { it("sanitizes provider-supplied account failures before they leave Mega-Debrid rotation", async () => {
const login = "private-user@example.test"; const login = "private-user@example.test";
const password = "provider-password-secret"; const password = "provider-password-secret";
+131 -1
View File
@@ -1283,6 +1283,136 @@ describe("download manager", () => {
expect(failures.has("realdebrid:rapidgator.net")).toBe(true); 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<string, unknown>;
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", () => { 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debrid-link-live-pool-refresh-"));
tempDirs.push(root); tempDirs.push(root);
@@ -12181,7 +12311,7 @@ describe("download manager", () => {
); );
const flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv"); 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); await waitFor(() => manager.getSnapshot().session.packageOrder.length === 0, 12000);
expect(fs.existsSync(flattenedPath)).toBe(true); expect(fs.existsSync(flattenedPath)).toBe(true);
+1 -1
View File
@@ -63,7 +63,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(1); expect(result.extracted).toBe(1);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
}); }, 15000);
it("emits progress callbacks with archiveName and percent", async () => { it("emits progress callbacks with archiveName and percent", async () => {
process.env.RD_EXTRACT_BACKEND = "jvm"; process.env.RD_EXTRACT_BACKEND = "jvm";
+1 -1
View File
@@ -104,7 +104,7 @@ describe("extractor", () => {
expect(fs.existsSync(validZipPath)).toBe(false); expect(fs.existsSync(validZipPath)).toBe(false);
expect(fs.existsSync(invalidZipPath)).toBe(true); expect(fs.existsSync(invalidZipPath)).toBe(true);
expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true);
}); }, 15000);
it("collects companion rar parts for cleanup", () => { it("collects companion rar parts for cleanup", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
+20
View File
@@ -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: []
});
});
});
+14
View File
@@ -14,6 +14,20 @@ vi.mock("electron", () => ({
})); }));
describe("reset controller boundary", () => { 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<typeof vi.fn> };
audit: ReturnType<typeof vi.fn>;
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 () => { it("audits reset completion only after the manager operation succeeds", async () => {
const packagePromise = Promise.resolve(); const packagePromise = Promise.resolve();
const itemPromise = Promise.resolve(); const itemPromise = Promise.resolve();
+20
View File
@@ -771,6 +771,26 @@ describe("account workspace", () => {
expect(html).toContain("Automatischer Fallback"); 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<AccountWorkspaceActions>)
});
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", () => { it("keeps add and edit dialogs separate and every secret field protected", () => {
const options = accountOptions(); const options = accountOptions();
const addHtml = renderToStaticMarkup( const addHtml = renderToStaticMarkup(
+1
View File
@@ -35,6 +35,7 @@ export function createVisualElectronApi(
fixture.snapshot.settings.debridLinkApiKeyDailyUsageBytes[keyId] = 0; fixture.snapshot.settings.debridLinkApiKeyDailyUsageBytes[keyId] = 0;
return clone(fixture.snapshot.settings); return clone(fixture.snapshot.settings);
}, },
resetMegaDebridCooldowns: async () => ({ cleared: 0 }),
createAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), 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) }), 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) }), updateAccountSecret: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }),