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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
+58
-1
@@ -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";
|
||||
|
||||
@@ -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<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", () => {
|
||||
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);
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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<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 () => {
|
||||
const packagePromise = Promise.resolve();
|
||||
const itemPromise = Promise.resolve();
|
||||
|
||||
@@ -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<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", () => {
|
||||
const options = accountOptions();
|
||||
const addHtml = renderToStaticMarkup(
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
Reference in New Issue
Block a user