diff --git a/package-lock.json b/package-lock.json index d15441d..b6337c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.3", + "version": "1.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.3", + "version": "1.4.4", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index fd86b59..a283950 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.3", + "version": "1.4.4", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 15c43ed..dbebe24 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1543,7 +1543,7 @@ export class DownloadManager extends EventEmitter { try { const unrestricted = await this.debridService.unrestrictLink(item.url); item.provider = unrestricted.provider; - item.retries = unrestricted.retriesUsed; + item.retries += unrestricted.retriesUsed; item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); fs.mkdirSync(pkg.outputDir, { recursive: true }); const existingTargetPath = String(item.targetPath || "").trim(); @@ -1562,11 +1562,9 @@ export class DownloadManager extends EventEmitter { const maxAttempts = REQUEST_RETRIES; let done = false; - let downloadRetries = 0; while (!done && item.attempts < maxAttempts) { item.attempts += 1; const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes); - downloadRetries += result.retriesUsed; active.resumable = result.resumable; if (!active.resumable && !active.nonResumableCounted) { active.nonResumableCounted = true; @@ -1603,8 +1601,6 @@ export class DownloadManager extends EventEmitter { done = true; } - - item.retries += downloadRetries; item.status = "completed"; item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; item.progressPercent = 100; @@ -1656,6 +1652,7 @@ export class DownloadManager extends EventEmitter { } else if (reason === "stall") { stallRetries += 1; if (stallRetries <= 2) { + item.retries += 1; item.status = "queued"; item.speedBps = 0; item.fullStatus = `Keine Daten empfangen, Retry ${stallRetries}/2`; @@ -1690,6 +1687,7 @@ export class DownloadManager extends EventEmitter { } if (shouldFreshRetry) { freshRetryUsed = true; + item.retries += 1; try { fs.rmSync(item.targetPath, { force: true }); } catch { @@ -1713,6 +1711,7 @@ export class DownloadManager extends EventEmitter { if (genericErrorRetries < maxGenericErrorRetries) { genericErrorRetries += 1; + item.retries += 1; item.status = "queued"; item.fullStatus = `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`; item.lastError = errorText; @@ -1746,7 +1745,7 @@ export class DownloadManager extends EventEmitter { directUrl: string, targetPath: string, knownTotal: number | null - ): Promise<{ retriesUsed: number; resumable: boolean }> { + ): Promise<{ resumable: boolean }> { const item = this.session.items[active.itemId]; if (!item) { throw new Error("Download-Item fehlt"); @@ -1781,6 +1780,7 @@ export class DownloadManager extends EventEmitter { } lastError = compactErrorText(error); if (attempt < REQUEST_RETRIES) { + item.retries += 1; item.fullStatus = `Verbindungsfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`; this.emitState(); await sleep(300 * attempt); @@ -1793,13 +1793,13 @@ export class DownloadManager extends EventEmitter { if (response.status === 416 && existingBytes > 0) { const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal; - if (expectedTotal && existingBytes >= expectedTotal) { + if (expectedTotal && existingBytes === expectedTotal) { item.totalBytes = expectedTotal; item.downloadedBytes = existingBytes; item.progressPercent = 100; item.speedBps = 0; item.updatedAt = nowMs(); - return { retriesUsed: attempt - 1, resumable: true }; + return { resumable: true }; } try { @@ -1815,16 +1815,22 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); this.emitState(); if (attempt < REQUEST_RETRIES) { + item.retries += 1; await sleep(280 * attempt); continue; } } const text = await response.text(); - lastError = compactErrorText(text || `HTTP ${response.status}`); + lastError = `HTTP ${response.status}`; + const responseText = compactErrorText(text || ""); + if (responseText && responseText !== "Unbekannter Fehler" && !/(^|\b)http\s*\d{3}\b/i.test(responseText)) { + lastError = `HTTP ${response.status}: ${responseText}`; + } if (this.settings.autoReconnect && [429, 503].includes(response.status)) { this.requestReconnect(`HTTP ${response.status}`); } if (attempt < REQUEST_RETRIES) { + item.retries += 1; item.fullStatus = `Serverfehler ${response.status}, retry ${attempt + 1}/${REQUEST_RETRIES}`; this.emitState(); await sleep(350 * attempt); @@ -2008,13 +2014,14 @@ export class DownloadManager extends EventEmitter { item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100; item.speedBps = 0; item.updatedAt = nowMs(); - return { retriesUsed: attempt - 1, resumable }; + return { resumable }; } catch (error) { if (active.abortController.signal.aborted || String(error).includes("aborted:")) { throw error; } lastError = compactErrorText(error); if (attempt < REQUEST_RETRIES) { + item.retries += 1; item.fullStatus = `Downloadfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`; this.emitState(); await sleep(350 * attempt); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index cfee316..e30740e 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -896,6 +896,123 @@ describe("download manager", () => { } }); + it("counts retries and resets stale 100% progress on persistent HTTP 416", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const staleBinary = Buffer.alloc(64 * 1024, 9); + const pkgDir = path.join(root, "downloads", "range-416-fail"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "broken.part3.rar"); + fs.writeFileSync(existingTargetPath, staleBinary); + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/range-416-fail") { + res.statusCode = 404; + res.end("not-found"); + return; + } + directCalls += 1; + res.statusCode = 416; + res.setHeader("Content-Range", "bytes */32768"); + res.end(""); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/range-416-fail`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "broken.part3.rar", + filesize: 32768 + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "range-416-fail-pkg"; + const itemId = "range-416-fail-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "range-416-fail", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "range-416-fail"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/range-416-fail", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: staleBinary.length, + totalBytes: staleBinary.length, + progressPercent: 100, + fileName: "broken.part3.rar", + targetPath: existingTargetPath, + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 45000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("failed"); + expect(item?.retries).toBeGreaterThan(0); + expect(item?.progressPercent).toBe(0); + expect(item?.downloadedBytes).toBe(0); + expect(item?.lastError).toContain("416"); + expect(directCalls).toBeGreaterThanOrEqual(3); + } finally { + server.close(); + await once(server, "close"); + } + }, 30000); + it("retries non-retriable HTTP statuses and eventually succeeds", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);