diff --git a/src/main/constants.ts b/src/main/constants.ts index 9e84362..5e29f13 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -64,6 +64,7 @@ export function defaultSettings(): AppSettings { reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, + retryLimit: REQUEST_RETRIES, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 81ad9f0..2825ca8 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -116,6 +116,22 @@ function getLowThroughputMinBytes(): number { return DEFAULT_LOW_THROUGHPUT_MIN_BYTES; } +function normalizeRetryLimit(value: unknown): number { + const num = Number(value); + if (!Number.isFinite(num)) { + return REQUEST_RETRIES; + } + return Math.max(0, Math.min(99, Math.floor(num))); +} + +function retryLimitLabel(retryLimit: number): string { + return retryLimit <= 0 ? "inf" : String(retryLimit); +} + +function retryLimitToMaxRetries(retryLimit: number): number { + return retryLimit <= 0 ? Number.MAX_SAFE_INTEGER : retryLimit; +} + type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; }; @@ -2975,8 +2991,13 @@ export class DownloadManager extends EventEmitter { active.stallRetries = retryState.stallRetries; active.genericErrorRetries = retryState.genericErrorRetries; active.unrestrictRetries = retryState.unrestrictRetries; - const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES); - const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES); + const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); + const retryDisplayLimit = retryLimitLabel(configuredRetryLimit); + const maxItemRetries = retryLimitToMaxRetries(configuredRetryLimit); + const maxItemAttempts = configuredRetryLimit <= 0 ? Number.MAX_SAFE_INTEGER : maxItemRetries + 1; + const maxGenericErrorRetries = maxItemRetries; + const maxUnrestrictRetries = maxItemRetries; + const maxStallRetries = maxItemRetries; while (true) { try { const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); @@ -3015,7 +3036,7 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); this.emitState(); - const maxAttempts = REQUEST_RETRIES; + const maxAttempts = maxItemAttempts; let done = false; while (!done && item.attempts < maxAttempts) { item.attempts += 1; @@ -3170,11 +3191,11 @@ export class DownloadManager extends EventEmitter { const stallErrorText = compactErrorText(error); const isSlowThroughput = stallErrorText.includes("slow_throughput"); active.stallRetries += 1; - if (active.stallRetries <= 2) { + if (active.stallRetries <= maxStallRetries) { item.retries += 1; const retryText = isSlowThroughput - ? `Zu wenig Datenfluss, Retry ${active.stallRetries}/2` - : `Keine Daten empfangen, Retry ${active.stallRetries}/2`; + ? `Zu wenig Datenfluss, Retry ${active.stallRetries}/${retryDisplayLimit}` + : `Keine Daten empfangen, Retry ${active.stallRetries}/${retryDisplayLimit}`; this.queueRetry(item, active, 350 * active.stallRetries, retryText); item.lastError = ""; this.persistSoon(); @@ -3277,9 +3298,13 @@ export class DownloadManager extends EventEmitter { throw new Error("Download-Item fehlt"); } + const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); + const retryDisplayLimit = retryLimitLabel(configuredRetryLimit); + const maxAttempts = configuredRetryLimit <= 0 ? Number.MAX_SAFE_INTEGER : configuredRetryLimit + 1; + let lastError = ""; let effectiveTargetPath = targetPath; - for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { let existingBytes = 0; try { const stat = await fs.promises.stat(effectiveTargetPath); @@ -3322,9 +3347,9 @@ export class DownloadManager extends EventEmitter { throw error; } lastError = compactErrorText(error); - if (attempt < REQUEST_RETRIES) { + if (attempt < maxAttempts) { item.retries += 1; - item.fullStatus = `Verbindungsfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`; + item.fullStatus = `Verbindungsfehler, retry ${attempt}/${retryDisplayLimit}`; this.emitState(); await sleep(300 * attempt); continue; @@ -3360,10 +3385,10 @@ export class DownloadManager extends EventEmitter { item.totalBytes = knownTotal && knownTotal > 0 ? knownTotal : null; item.progressPercent = 0; item.speedBps = 0; - item.fullStatus = `Range-Konflikt (HTTP 416), starte neu ${Math.min(REQUEST_RETRIES, attempt + 1)}/${REQUEST_RETRIES}`; + item.fullStatus = `Range-Konflikt (HTTP 416), starte neu ${attempt}/${retryDisplayLimit}`; item.updatedAt = nowMs(); this.emitState(); - if (attempt < REQUEST_RETRIES) { + if (attempt < maxAttempts) { item.retries += 1; await sleep(280 * attempt); continue; @@ -3380,9 +3405,9 @@ export class DownloadManager extends EventEmitter { if (this.settings.autoReconnect && [429, 503].includes(response.status)) { this.requestReconnect(`HTTP ${response.status}`); } - if (attempt < REQUEST_RETRIES) { + if (attempt < maxAttempts) { item.retries += 1; - item.fullStatus = `Serverfehler ${response.status}, retry ${attempt + 1}/${REQUEST_RETRIES}`; + item.fullStatus = `Serverfehler ${response.status}, retry ${attempt}/${retryDisplayLimit}`; this.emitState(); await sleep(350 * attempt); continue; @@ -3593,13 +3618,15 @@ export class DownloadManager extends EventEmitter { if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } + let pausedDuringWait = false; while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { + pausedDuringWait = true; item.status = "paused"; item.fullStatus = "Pausiert"; this.emitState(); await sleep(120); } - if (!this.session.paused) { + if (pausedDuringWait) { throughputWindowStartedAt = nowMs(); throughputWindowBytes = 0; } @@ -3709,9 +3736,9 @@ export class DownloadManager extends EventEmitter { throw error; } lastError = compactErrorText(error); - if (attempt < REQUEST_RETRIES) { + if (attempt < maxAttempts) { item.retries += 1; - item.fullStatus = `Downloadfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`; + item.fullStatus = `Downloadfehler, retry ${attempt}/${retryDisplayLimit}`; this.emitState(); await sleep(350 * attempt); continue; diff --git a/src/main/storage.ts b/src/main/storage.ts index 7142ddf..2bb3487 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -96,6 +96,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { autoResumeOnStart: Boolean(settings.autoResumeOnStart), autoReconnect: Boolean(settings.autoReconnect), maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50), + retryLimit: clampNumber(settings.retryLimit, defaults.retryLimit, 0, 99), reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), completedCleanupPolicy: settings.completedCleanupPolicy, speedLimitEnabled: Boolean(settings.speedLimitEnabled), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 82182de..fcd1f75 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -53,7 +53,7 @@ const emptySnapshot = (): UiSnapshot => ({ cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", - maxParallel: 4, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", + maxParallel: 4, retryLimit: 3, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", bandwidthSchedules: [] }, @@ -1497,6 +1497,7 @@ export function App(): ReactElement {

Queue, Limits & Reconnect

setNum("maxParallel", Number(e.target.value) || 1)} />
+
setNum("retryLimit", Math.max(0, Math.min(99, Number(e.target.value) || 0)))} />
setNum("reconnectWaitSeconds", Number(e.target.value) || 45)} />
diff --git a/src/shared/types.ts b/src/shared/types.ts index fbcc1a2..185aacf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -64,6 +64,7 @@ export interface AppSettings { reconnectWaitSeconds: number; completedCleanupPolicy: FinishedCleanupPolicy; maxParallel: number; + retryLimit: number; speedLimitEnabled: boolean; speedLimitKbps: number; speedLimitMode: SpeedMode; diff --git a/tests/storage.test.ts b/tests/storage.test.ts index db7a85d..237fb02 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -80,6 +80,7 @@ describe("settings storage", () => { completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"], speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"], maxParallel: 0, + retryLimit: 999, reconnectWaitSeconds: 9999, speedLimitKbps: -1, outputDir: " ", @@ -96,6 +97,7 @@ describe("settings storage", () => { expect(normalized.completedCleanupPolicy).toBe("never"); expect(normalized.speedLimitMode).toBe("global"); expect(normalized.maxParallel).toBe(1); + expect(normalized.retryLimit).toBe(99); expect(normalized.reconnectWaitSeconds).toBe(600); expect(normalized.speedLimitKbps).toBe(0); expect(normalized.outputDir).toBe(defaultSettings().outputDir); @@ -115,6 +117,7 @@ describe("settings storage", () => { providerPrimary: "not-valid", completedCleanupPolicy: "not-valid", maxParallel: "999", + retryLimit: "-3", reconnectWaitSeconds: "1", speedLimitMode: "not-valid", updateRepo: "" @@ -126,6 +129,7 @@ describe("settings storage", () => { expect(loaded.providerPrimary).toBe("realdebrid"); expect(loaded.completedCleanupPolicy).toBe("never"); expect(loaded.maxParallel).toBe(50); + expect(loaded.retryLimit).toBe(0); expect(loaded.reconnectWaitSeconds).toBe(10); expect(loaded.speedLimitMode).toBe("global"); expect(loaded.updateRepo).toBe(defaultSettings().updateRepo); @@ -312,6 +316,7 @@ describe("settings storage", () => { const defaults = defaultSettings(); expect(loaded.providerPrimary).toBe(defaults.providerPrimary); expect(loaded.maxParallel).toBe(defaults.maxParallel); + expect(loaded.retryLimit).toBe(defaults.retryLimit); expect(loaded.outputDir).toBe(defaults.outputDir); expect(loaded.cleanupMode).toBe(defaults.cleanupMode); }); @@ -420,6 +425,7 @@ describe("settings storage", () => { expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode); expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch); expect(loaded.minimizeToTray).toBe(defaults.minimizeToTray); + expect(loaded.retryLimit).toBe(defaults.retryLimit); expect(loaded.collectMkvToLibrary).toBe(defaults.collectMkvToLibrary); expect(loaded.mkvLibraryDir).toBe(defaults.mkvLibraryDir); expect(loaded.theme).toBe(defaults.theme);