From 53212f45e3aa5422cdf66728f4ea0cfd9d072982 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 18:11:50 +0100 Subject: [PATCH] Release v1.4.3 with unified controls and resilient retries --- package-lock.json | 4 +- package.json | 2 +- src/main/app-controller.ts | 2 +- src/main/download-manager.ts | 105 +++++++++++++-- src/renderer/App.tsx | 27 +++- src/renderer/styles.css | 43 ++++++- tests/download-manager.test.ts | 227 ++++++++++++++++++++++++++++++++- 7 files changed, 384 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index d6409bc..d15441d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.3.1", + "version": "1.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.3.1", + "version": "1.4.3", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 8d2d6f1..fd86b59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.2", + "version": "1.4.3", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 72cf2cc..8ac4bed 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -130,7 +130,7 @@ export class AppController { return this.manager.getStartConflicts(); } - public resolveStartConflict(packageId: string, policy: DuplicatePolicy): StartConflictResolutionResult { + public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise { return this.manager.resolveStartConflict(packageId, policy); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 3194d07..15c43ed 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -528,6 +528,7 @@ export class DownloadManager extends EventEmitter { } public getStartConflicts(): StartConflictEntry[] { + const hasFilesByExtractDir = new Map(); const conflicts: StartConflictEntry[] = []; for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; @@ -546,7 +547,19 @@ export class DownloadManager extends EventEmitter { continue; } - if (this.directoryHasAnyFiles(pkg.extractDir)) { + if (!this.isPackageSpecificExtractDir(pkg)) { + continue; + } + + const extractDirKey = pathKey(pkg.extractDir); + const hasExtractedFiles = hasFilesByExtractDir.has(extractDirKey) + ? Boolean(hasFilesByExtractDir.get(extractDirKey)) + : this.directoryHasAnyFiles(pkg.extractDir); + if (!hasFilesByExtractDir.has(extractDirKey)) { + hasFilesByExtractDir.set(extractDirKey, hasExtractedFiles); + } + + if (hasExtractedFiles) { conflicts.push({ packageId: pkg.id, packageName: pkg.name, @@ -557,7 +570,7 @@ export class DownloadManager extends EventEmitter { return conflicts; } - public resolveStartConflict(packageId: string, policy: DuplicatePolicy): StartConflictResolutionResult { + public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled) { return { skipped: false, overwritten: false }; @@ -581,13 +594,16 @@ export class DownloadManager extends EventEmitter { } if (policy === "overwrite") { - try { - fs.rmSync(pkg.extractDir, { recursive: true, force: true }); - } catch { - // ignore + const canDeleteExtractDir = this.isPackageSpecificExtractDir(pkg) && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir); + if (canDeleteExtractDir) { + try { + await fs.promises.rm(pkg.extractDir, { recursive: true, force: true }); + } catch { + // ignore + } } try { - fs.rmSync(pkg.outputDir, { recursive: true, force: true }); + await fs.promises.rm(pkg.outputDir, { recursive: true, force: true }); } catch { // ignore } @@ -626,6 +642,31 @@ export class DownloadManager extends EventEmitter { return { skipped: false, overwritten: false }; } + private isPackageSpecificExtractDir(pkg: PackageEntry): boolean { + const expectedName = sanitizeFilename(pkg.name).toLowerCase(); + if (!expectedName) { + return false; + } + return path.basename(pkg.extractDir).toLowerCase() === expectedName; + } + + private isExtractDirSharedWithOtherPackages(packageId: string, extractDir: string): boolean { + const key = pathKey(extractDir); + for (const otherId of this.session.packageOrder) { + if (otherId === packageId) { + continue; + } + const other = this.session.packages[otherId]; + if (!other || other.cancelled) { + continue; + } + if (pathKey(other.extractDir) === key) { + return true; + } + } + return false; + } + private async resolveQueuedFilenames(unresolvedByLink: Map): Promise { try { let changed = false; @@ -1496,6 +1537,8 @@ export class DownloadManager extends EventEmitter { let freshRetryUsed = false; let stallRetries = 0; + let genericErrorRetries = 0; + const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES); while (true) { try { const unrestricted = await this.debridService.unrestrictLink(item.url); @@ -1633,6 +1676,18 @@ export class DownloadManager extends EventEmitter { } else { const errorText = compactErrorText(error); const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText); + const isHttp416 = /(^|\D)416(\D|$)/.test(errorText); + if (isHttp416) { + try { + fs.rmSync(item.targetPath, { force: true }); + } catch { + // ignore + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + } if (shouldFreshRetry) { freshRetryUsed = true; try { @@ -1655,6 +1710,23 @@ export class DownloadManager extends EventEmitter { await sleep(450); continue; } + + if (genericErrorRetries < maxGenericErrorRetries) { + genericErrorRetries += 1; + item.status = "queued"; + item.fullStatus = `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`; + item.lastError = errorText; + item.attempts = 0; + item.speedBps = 0; + item.updatedAt = nowMs(); + active.abortController = new AbortController(); + active.abortReason = "none"; + this.persistSoon(); + this.emitState(); + await sleep(Math.min(1200, 300 * genericErrorRetries)); + continue; + } + item.status = "failed"; this.recordRunOutcome(item.id, "failed"); item.lastError = errorText; @@ -1729,13 +1801,30 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); return { retriesUsed: attempt - 1, resumable: true }; } + + try { + fs.rmSync(effectiveTargetPath, { force: true }); + } catch { + // ignore + } + item.downloadedBytes = 0; + 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.updatedAt = nowMs(); + this.emitState(); + if (attempt < REQUEST_RETRIES) { + await sleep(280 * attempt); + continue; + } } const text = await response.text(); lastError = compactErrorText(text || `HTTP ${response.status}`); if (this.settings.autoReconnect && [429, 503].includes(response.status)) { this.requestReconnect(`HTTP ${response.status}`); } - if (canRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + if (attempt < REQUEST_RETRIES) { item.fullStatus = `Serverfehler ${response.status}, retry ${attempt + 1}/${REQUEST_RETRIES}`; this.emitState(); await sleep(350 * attempt); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9b4cd4a..f6704ff 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -404,6 +404,14 @@ export function App(): ReactElement { }); }; + const onStartPauseClick = async (): Promise => { + if (snapshot.session.running) { + await performQuickAction(() => window.rd.togglePause()); + return; + } + await onStartDownloads(); + }; + const onAddLinks = async (): Promise => { await performQuickAction(async () => { await persistDraftSettings(); @@ -661,9 +669,9 @@ export function App(): ReactElement { onDrop={onDrop} >
+
-

Debrid Download Manager

- Multi-Provider Workflow +

Multi Debrid Downloader

{snapshot.speedText}
@@ -675,12 +683,17 @@ export function App(): ReactElement {
-
- - +
+
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 192cca2..47abec9 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -69,9 +69,18 @@ body, } .top-header { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; + gap: 10px; +} + +.header-spacer { + min-height: 1px; +} + +.title-block { + text-align: center; } .title-block h1 { @@ -91,6 +100,7 @@ body, gap: 12px; color: var(--muted); font-size: 13px; + justify-self: end; } .control-strip { @@ -113,6 +123,11 @@ body, gap: 8px; } +.buttons-right { + margin-left: auto; + justify-content: flex-end; +} + .btn { background: var(--button-bg); color: var(--text); @@ -677,6 +692,21 @@ td { } @media (max-width: 1100px) { + .top-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + + .header-spacer { + display: none; + } + + .title-block { + text-align: center; + } + .control-strip { flex-direction: column; align-items: flex-start; @@ -684,7 +714,14 @@ td { .metrics { flex-direction: column; - align-items: flex-end; + align-items: center; + justify-self: center; + } + + .buttons-right { + width: 100%; + margin-left: 0; + justify-content: flex-end; } .settings-toolbar { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1de97b4..cfee316 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -754,6 +754,225 @@ describe("download manager", () => { } }); + it("recovers from HTTP 416 by restarting download from zero", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 3); + const pkgDir = path.join(root, "downloads", "range-reset"); + fs.mkdirSync(pkgDir, { recursive: true }); + const existingTargetPath = path.join(pkgDir, "reset.mkv"); + const partialSize = 64 * 1024; + fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize)); + + let saw416 = false; + let fullRestarted = false; + let requestCount = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/range-reset") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + requestCount += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + + if (requestCount === 1 && start === partialSize) { + saw416 = true; + res.statusCode = 416; + res.setHeader("Content-Range", "bytes */32768"); + res.end(""); + return; + } + + if (start === 0) { + fullRestarted = true; + } + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + 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-reset`; + + 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: "reset.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const session = emptySession(); + const packageId = "range-reset-pkg"; + const itemId = "range-reset-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "range-reset", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "range-reset"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/range-reset", + provider: null, + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "reset.mkv", + 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, 25000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(saw416).toBe(true); + expect(fullRestarted).toBe(true); + expect(fs.statSync(existingTargetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("retries non-retriable HTTP statuses and eventually succeeds", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 5); + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/status-retry") { + res.statusCode = 404; + res.end("not-found"); + return; + } + directCalls += 1; + if (directCalls <= 2) { + res.statusCode = 403; + res.end("forbidden"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + 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}/status-retry`; + + 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: "status-retry.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "status-retry", links: ["https://dummy/status-retry"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThanOrEqual(3); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("normalizes stale running state on startup", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -887,7 +1106,7 @@ describe("download manager", () => { expect(conflicts[0]?.packageId).toBe(packageId); }); - it("resolves start conflict by skipping package", () => { + it("resolves start conflict by skipping package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -946,13 +1165,13 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); - const result = manager.resolveStartConflict(packageId, "skip"); + const result = await manager.resolveStartConflict(packageId, "skip"); expect(result.skipped).toBe(true); expect(manager.getSnapshot().session.packages[packageId]).toBeUndefined(); expect(manager.getSnapshot().session.items[itemId]).toBeUndefined(); }); - it("resolves start conflict by overwriting and resetting queued package", () => { + it("resolves start conflict by overwriting and resetting queued package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -1012,7 +1231,7 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); - const result = manager.resolveStartConflict(packageId, "overwrite"); + const result = await manager.resolveStartConflict(packageId, "overwrite"); expect(result.overwritten).toBe(true); const snapshot = manager.getSnapshot(); const item = snapshot.session.items[itemId];