diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 6c88be9..76ec56c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -22,6 +22,7 @@ import { StartConflictResolutionResult, UiSnapshot, DebridAccountStatus } from "../shared/types"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { addDebridLinkApiKeyDailyUsageBytes, addDebridLinkApiKeyTotalUsageBytes, @@ -51,7 +52,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, 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"; @@ -701,6 +702,12 @@ function isTemporaryUnrestrictError(errorText: string): boolean { || text.includes("worker error"); } +export function transientResolveRetryDelayMs(retryCount: number): number { + const steps = [3000, 6000, 10000]; + const n = Math.max(1, Math.floor(Number(retryCount) || 1)); + return steps[Math.min(n - 1, steps.length - 1)]; +} + function isFinishedStatus(status: DownloadStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } @@ -7974,7 +7981,7 @@ export class DownloadManager extends EventEmitter { private getSerializedValidatingLimit(provider: DebridProvider | null): number { if (provider === "megadebrid-web") { - return 1; + return Math.max(1, getAvailableMegaDebridAccounts(this.settings).length); } return Number.MAX_SAFE_INTEGER; } @@ -9297,6 +9304,35 @@ export class DownloadManager extends EventEmitter { return; } + if (isMegaDebridTransientResolveFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { + active.unrestrictRetries += 1; + item.retries += 1; + const transientDelayMs = transientResolveRetryDelayMs(active.unrestrictRetries); + const transientReason = germanMegaDebridResolveReason(errorText); + logger.warn(`Transienter Mega-Debrid-Resolve-Fehler: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${transientDelayMs}ms, error=${errorText}, link=${item.url.slice(0, 80)}`); + if (item.downloadedBytes > 0) { + const targetFile = this.claimedTargetPathByItem.get(item.id) || ""; + if (targetFile) { + try { fs.rmSync(targetFile, { force: true }); } catch { } + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; + this.dropItemContribution(item.id); + } + this.queueRetry( + item, + active, + transientDelayMs, + `${transientReason} — neuer Versuch ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(transientDelayMs / 1000)}s)` + ); + item.lastError = transientReason; + this.persistSoon(); + this.emitState(); + return; + } + if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText); if (debridLinkCooldown) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d56fb7e..068bb99 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2561,7 +2561,7 @@ export function App(): ReactElement { const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id); const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id); rows.push({ - rowKey: `mega-${acc.id}`, + rowKey: `mega-${entry.kind}-${acc.id}`, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, @@ -2684,7 +2684,8 @@ export function App(): ReactElement { else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; } else { statusCls = "ok"; statusText = st.message || "Premium Account"; } const isProblem = statusCls === "invalid"; - const username = st && st.email ? st.email : row.username; + const username = row.username; + const usernameTitle = st && st.email && st.email.toLowerCase() !== row.username.toLowerCase() ? `${row.username} (Mail: ${st.email})` : row.username; const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—"; const traffic = row.dailyLimitBytes > 0 ? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig` @@ -2714,7 +2715,7 @@ export function App(): ReactElement { ? : {statusText}} - {username} + {username} {expiry} {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; + } + }); +});