{row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && (
@@ -6784,7 +6785,13 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
}, [packageId, item.id, onContextMenu]);
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
- const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
+ const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
+ const lastErrorText = String(item.lastError || "").trim();
+ const statusTitle = displayStatus
+ ? (lastErrorText && lastErrorText !== displayStatus && !displayStatus.includes(lastErrorText)
+ ? `${displayStatus}${retrySuffix}\n${lastErrorText}`
+ : `${displayStatus}${retrySuffix}`)
+ : lastErrorText;
return (
{
}
}, 20000);
+ it("retries a transient Mega-Debrid resolve failure fast (no long cooldown) with a German reason", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
+ tempDirs.push(root);
+
+ let unrestrictCalls = 0;
+
+ const manager = new DownloadManager(
+ {
+ ...defaultSettings(),
+ megaCredentials: "mega-user:mega-pass",
+ megaDebridWebEnabled: true,
+ megaDebridApiEnabled: false,
+ megaDebridPreferApi: false,
+ providerOrder: [],
+ providerPrimary: "megadebrid",
+ providerSecondary: "none",
+ providerTertiary: "none",
+ outputDir: path.join(root, "downloads"),
+ extractDir: path.join(root, "extract"),
+ autoExtract: false,
+ autoReconnect: false,
+ enableIntegrityCheck: false,
+ maxParallel: 1
+ },
+ emptySession(),
+ createStoragePaths(path.join(root, "state")),
+ {
+ megaWebUnrestrict: vi.fn(async (_link: string, _signal?: AbortSignal) => {
+ unrestrictCalls += 1;
+ throw new Error("Fichier supprimé chez l'hébergeur");
+ })
+ }
+ );
+
+ manager.addPackages([{
+ name: "mega-transient",
+ links: ["https://rapidgator.net/file/mega-transient.part1.rar.html"]
+ }]);
+
+ await manager.start();
+ await waitFor(
+ () => String(Object.values(manager.getSnapshot().session.items)[0]?.fullStatus || "").includes("neuer Versuch"),
+ 12000
+ );
+
+ const item = Object.values(manager.getSnapshot().session.items)[0];
+ expect(item.status).not.toBe("failed");
+ expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
+ expect(item.lastError).toBe("Datei beim Hoster gerade nicht abrufbar");
+ expect(String(item.fullStatus || "")).toContain("Datei beim Hoster gerade nicht abrufbar");
+ expect(String(item.fullStatus || "")).toContain("neuer Versuch");
+ const countdownMatch = String(item.fullStatus || "").match(/\((\d+)s\)/);
+ expect(countdownMatch).not.toBeNull();
+ expect(Number(countdownMatch![1])).toBeLessThanOrEqual(10);
+
+ await waitFor(() => unrestrictCalls >= 2, 12000);
+
+ manager.stop();
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ }, 20000);
+
it("limits Mega-Debrid Web validating starts to one item at a time", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -6506,6 +6567,81 @@ describe("download manager", () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
+ it("runs one Mega-Debrid Web validation per available account in parallel", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
+ tempDirs.push(root);
+
+ let unrestrictCalls = 0;
+ const pendingRejectors = new Set<(error: Error) => void>();
+
+ const manager = new DownloadManager(
+ {
+ ...defaultSettings(),
+ megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b",
+ megaDebridWebEnabled: true,
+ megaDebridApiEnabled: false,
+ megaDebridPreferApi: false,
+ providerOrder: [],
+ providerPrimary: "megadebrid",
+ providerSecondary: "none",
+ providerTertiary: "none",
+ outputDir: path.join(root, "downloads"),
+ extractDir: path.join(root, "extract"),
+ autoExtract: false,
+ autoReconnect: false,
+ enableIntegrityCheck: false,
+ maxParallel: 4
+ },
+ emptySession(),
+ createStoragePaths(path.join(root, "state")),
+ {
+ megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => {
+ unrestrictCalls += 1;
+ return await new Promise((resolve, reject) => {
+ const rejector = (error: Error): void => {
+ signal?.removeEventListener("abort", onAbort);
+ pendingRejectors.delete(rejector);
+ reject(error);
+ };
+ const onAbort = (): void => {
+ rejector(new Error("aborted:test-mega-web"));
+ };
+ if (signal?.aborted) {
+ onAbort();
+ return;
+ }
+ signal?.addEventListener("abort", onAbort, { once: true });
+ pendingRejectors.add(rejector);
+ });
+ })
+ }
+ );
+
+ manager.addPackages([{
+ name: "mega-web-parallel",
+ links: [
+ "https://rapidgator.net/file/mega-web-1.part1.rar.html",
+ "https://rapidgator.net/file/mega-web-2.part2.rar.html",
+ "https://rapidgator.net/file/mega-web-3.part3.rar.html"
+ ]
+ }]);
+
+ await manager.start();
+ await waitFor(() => unrestrictCalls === 2, 10000);
+ await new Promise((resolve) => setTimeout(resolve, 250));
+
+ const items = Object.values(manager.getSnapshot().session.items);
+ expect(items.filter((item) => item.status === "validating")).toHaveLength(2);
+ expect(items.filter((item) => item.status === "queued")).toHaveLength(1);
+ expect(unrestrictCalls).toBe(2);
+
+ manager.stop();
+ for (const reject of Array.from(pendingRejectors)) {
+ reject(new Error("aborted:test-mega-web"));
+ }
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
+
it("shows the same AllDebrid countdown for all immediately free slots", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
diff --git a/tests/mega-debrid-errors.test.ts b/tests/mega-debrid-errors.test.ts
index 393d083..2a31f48 100644
--- a/tests/mega-debrid-errors.test.ts
+++ b/tests/mega-debrid-errors.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../src/shared/mega-debrid-errors";
+import { isMegaDebridResolveFailure, germanMegaDebridResolveReason, isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
describe("isMegaDebridResolveFailure", () => {
it("detects the real Mega-Debrid French resolve-failure phrase", () => {
@@ -38,3 +38,27 @@ describe("germanMegaDebridResolveReason (transient wording, NOT 'tot')", () => {
expect(germanMegaDebridResolveReason("Fichier introuvable")).toBe("Datei beim Hoster nicht gefunden");
});
});
+
+describe("isMegaDebridTransientResolveFailure (matches raw French AND rendered German)", () => {
+ it("matches the raw French phrase that may reach the download-manager", () => {
+ expect(isMegaDebridTransientResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
+ });
+
+ it("matches the German rendered reason that classifyAccountFailure produces", () => {
+ const aggregated = "Mega-Debrid (Account 1/4, ab***@x): Datei beim Hoster gerade nicht abrufbar | Mega-Debrid (Account 2/4, cd***@y): Datei beim Hoster gerade nicht abrufbar";
+ expect(isMegaDebridTransientResolveFailure(aggregated)).toBe(true);
+ });
+
+ it("matches the German not-found rendered reason", () => {
+ expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Datei beim Hoster nicht gefunden")).toBe(true);
+ });
+
+ it("does NOT match a Mega-Debrid timeout/abort (that has its own account cooldown path)", () => {
+ expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Abbruch/Timeout nach 60s")).toBe(false);
+ });
+
+ it("does NOT match unrelated provider errors", () => {
+ expect(isMegaDebridTransientResolveFailure("AllDebrid: zu viele aktive Downloads")).toBe(false);
+ expect(isMegaDebridTransientResolveFailure("Debrid-Link: badToken")).toBe(false);
+ });
+});
diff --git a/tests/unrestrict-retry.test.ts b/tests/unrestrict-retry.test.ts
new file mode 100644
index 0000000..36dc973
--- /dev/null
+++ b/tests/unrestrict-retry.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { transientResolveRetryDelayMs } from "../src/main/download-manager";
+
+describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
+ it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
+ expect(transientResolveRetryDelayMs(1)).toBeLessThanOrEqual(3000);
+ });
+
+ it("ramps gently and caps at 10s", () => {
+ expect(transientResolveRetryDelayMs(2)).toBeLessThanOrEqual(7000);
+ expect(transientResolveRetryDelayMs(3)).toBeLessThanOrEqual(10000);
+ expect(transientResolveRetryDelayMs(10)).toBe(10000);
+ expect(transientResolveRetryDelayMs(100)).toBe(10000);
+ });
+
+ it("never schedules anywhere near the 5s..120s exponential cap", () => {
+ for (let n = 1; n <= 50; n += 1) {
+ expect(transientResolveRetryDelayMs(n)).toBeLessThanOrEqual(10000);
+ expect(transientResolveRetryDelayMs(n)).toBeGreaterThanOrEqual(1000);
+ }
+ });
+
+ it("is monotonic non-decreasing", () => {
+ let prev = 0;
+ for (let n = 1; n <= 12; n += 1) {
+ const d = transientResolveRetryDelayMs(n);
+ expect(d).toBeGreaterThanOrEqual(prev);
+ prev = d;
+ }
+ });
+});