diff --git a/CHANGELOG.md b/CHANGELOG.md index 9330ed5..e02776e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert. +## 1.4.33 - 2026-03-02 + +Hotfix-Release fuer zwei reale Produktionsprobleme: falsche Gesamt-Statistik bei leerer Queue und stilles DLC-Import-Failure bei Drag-and-Drop. + +### Fixes + +- **Stats-Anzeige korrigiert ("Gesamt" bei leerer Queue):** + - Wenn keine Pakete/Items mehr vorhanden sind, werden persistierte Run-Bytes und Run-Timestamps jetzt sauber auf 0 zurueckgesetzt. + - Dadurch verschwindet die Ghost-Anzeige wie z. B. `Gesamt: 19.99 GB` bei `Pakete: 0 / Dateien: 0`. + - Reset greift in den relevanten Pfaden (`getStats`, `clearAll`, Paket-Entfernung, Startup-Normalisierung). + +- **DLC Drag-and-Drop Import gehaertet:** + - Lokale DLC-Fehler wie `Ungültiges DLC-Padding` blockieren den Fallback zu dcrypt nicht mehr. + - Oversize/invalid-size DLCs werden weiterhin defensiv behandelt, aber valide Dateien im gleichen Batch werden nicht mehr still geschluckt. + - Wenn alle DLC-Imports fehlschlagen, wird jetzt ein klarer Fehler mit Ursache geworfen statt still `0 Paket(e), 0 Link(s)` zu melden. + +- **UI-Rueckmeldung verbessert:** + - Bei DLC-Import mit `0` Treffern zeigt die UI jetzt eine klare Meldung (`Keine gültigen Links in den DLC-Dateien gefunden`) statt eines irrefuehrenden Erfolgs-Toast. + +### Tests + +- Neue/erweiterte Tests fuer: + - Reset von `totalDownloadedBytes`/Stats bei leerer Queue. + - DLC-Fallback-Pfad bei lokalen Decrypt-Exceptions. + - Fehlerausgabe bei vollstaendig fehlgeschlagenem DLC-Import. +- Validierung: + - `npx tsc --noEmit` erfolgreich + - `npm test` erfolgreich (`283/283`) + - `npm run self-check` erfolgreich + ## 1.4.32 - 2026-03-01 Diese Version erweitert den Auto-Renamer stark fuer reale Scene-/TV-Release-Strukturen (nested und flat) und fuehrt eine intensive Renamer-Regression mit zusaetzlichen Edge-Case- und Stress-Checks ein. diff --git a/package-lock.json b/package-lock.json index 5eefac7..a3862a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.32", + "version": "1.4.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.32", + "version": "1.4.33", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index b4c9b73..8db7fb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.32", + "version": "1.4.33", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/container.ts b/src/main/container.ts index 13f6212..a4e6bd0 100644 --- a/src/main/container.ts +++ b/src/main/container.ts @@ -7,6 +7,11 @@ import { ParsedPackageInput } from "../shared/types"; const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024; +function isContainerSizeValidationError(error: unknown): boolean { + const text = compactErrorText(error); + return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text); +} + function decodeDcryptPayload(responseText: string): unknown { let text = String(responseText || "").trim(); const m = text.match(/]*>([\s\S]*?)<\/textarea>/i); @@ -187,30 +192,51 @@ async function decryptDlcViaDcrypt(filePath: string): Promise { const out: ParsedPackageInput[] = []; + const failures: string[] = []; + let sawDlc = false; for (const filePath of filePaths) { if (path.extname(filePath).toLowerCase() !== ".dlc") { continue; } + sawDlc = true; let packages: ParsedPackageInput[] = []; + let fileFailed = false; + let fileFailureReasons: string[] = []; try { packages = await decryptDlcLocal(filePath); } catch (error) { - if (/zu groß|ungültig/i.test(compactErrorText(error))) { + if (isContainerSizeValidationError(error)) { + failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`); continue; } + fileFailed = true; + fileFailureReasons.push(`lokal: ${compactErrorText(error)}`); packages = []; } if (packages.length === 0) { try { packages = await decryptDlcViaDcrypt(filePath); } catch (error) { - if (/zu groß|ungültig/i.test(compactErrorText(error))) { + if (isContainerSizeValidationError(error)) { + failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`); continue; } + fileFailed = true; + fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`); packages = []; } } + if (packages.length === 0 && fileFailed) { + failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`); + } out.push(...packages); } + + if (out.length === 0 && sawDlc && failures.length > 0) { + const details = failures.slice(0, 2).join(" | "); + const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : ""; + throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`); + } + return out; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 5dbf54b..0d1218b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -681,6 +681,8 @@ export class DownloadManager extends EventEmitter { return this.statsCache; } + this.resetSessionTotalsIfQueueEmpty(); + let totalDownloaded = 0; let totalFiles = 0; for (const item of Object.values(this.session.items)) { @@ -714,6 +716,25 @@ export class DownloadManager extends EventEmitter { return stats; } + private resetSessionTotalsIfQueueEmpty(): void { + if (this.itemCount > 0 || this.session.packageOrder.length > 0) { + return; + } + if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) { + return; + } + + this.session.totalDownloadedBytes = 0; + this.session.runStartedAt = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; + this.statsCache = null; + this.statsCacheAt = 0; + } + public renamePackage(packageId: string, newName: string): void { const pkg = this.session.packages[packageId]; if (!pkg) { @@ -922,6 +943,7 @@ export class DownloadManager extends EventEmitter { this.nonResumableActive = 0; this.retryAfterByItem.clear(); this.retryStateByItem.clear(); + this.resetSessionTotalsIfQueueEmpty(); this.persistNow(); this.emitState(true); } @@ -1966,6 +1988,7 @@ export class DownloadManager extends EventEmitter { pkg.status = "completed"; } } + this.resetSessionTotalsIfQueueEmpty(); this.persistSoon(); } @@ -2371,6 +2394,7 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.delete(packageId); this.runCompletedPackages.delete(packageId); this.hybridExtractRequeue.delete(packageId); + this.resetSessionTotalsIfQueueEmpty(); } private async ensureScheduler(): Promise { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index bf32213..a65bdf6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -703,7 +703,11 @@ export function App(): ReactElement { if (files.length === 0) { return; } await persistDraftSettings(); const result = await window.rd.addContainers(files); - showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (result.addedLinks > 0) { + showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + } else { + showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); + } }, (error) => { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }); @@ -721,7 +725,11 @@ export function App(): ReactElement { await performQuickAction(async () => { await persistDraftSettings(); const result = await window.rd.addContainers(dlc); - showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (result.addedLinks > 0) { + showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + } else { + showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); + } }, (error) => { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }); diff --git a/tests/container.test.ts b/tests/container.test.ts index 4b48042..503f8e7 100644 --- a/tests/container.test.ts +++ b/tests/container.test.ts @@ -78,4 +78,43 @@ describe("container", () => { // Should have tried both! expect(fetchSpy).toHaveBeenCalledTimes(2); }); + + it("falls back to dcrypt when local decryption throws invalid padding", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "invalid-local.dlc"); + fs.writeFileSync(filePath, "X".repeat(120)); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response(`${Buffer.alloc(16).toString("base64")}`, { status: 200 }); + } + return new Response("http://example.com/fallback1", { status: 200 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await importDlcContainers([filePath]); + expect(result).toHaveLength(1); + expect(result[0].links).toEqual(["http://example.com/fallback1"]); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("throws clear error when all dlc imports fail", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); + tempDirs.push(dir); + const filePath = path.join(dir, "broken.dlc"); + fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all")); + + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + const urlStr = String(url); + if (urlStr.includes("service.jdownloader.org")) { + return new Response("", { status: 404 }); + } + return new Response("upstream failure", { status: 500 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i); + }); }); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index bbdb536..663da3b 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -2399,6 +2399,103 @@ describe("download manager", () => { expect(summary).toBeNull(); }); + it("shows zero total when queue is empty despite stale persisted bytes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + session.totalDownloadedBytes = 19.99 * 1024 * 1024 * 1024; + session.runStartedAt = Date.now() - 5 * 60 * 1000; + + 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")) + ); + + const snapshot = manager.getSnapshot(); + expect(snapshot.stats.totalPackages).toBe(0); + expect(snapshot.stats.totalFiles).toBe(0); + expect(snapshot.stats.totalDownloaded).toBe(0); + expect(snapshot.session.totalDownloadedBytes).toBe(0); + expect(snapshot.session.runStartedAt).toBe(0); + }); + + it("clearAll resets total bytes and stats", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "pkg-clear"; + const itemId = "item-clear"; + const now = Date.now() - 1000; + const outputDir = path.join(root, "downloads", "pkg-clear"); + const extractDir = path.join(root, "extract", "pkg-clear"); + const targetPath = path.join(outputDir, "episode.mkv"); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "pkg-clear", + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt: now, + updatedAt: now + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/item-clear", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 1024, + totalBytes: 1024, + progressPercent: 100, + fileName: "episode.mkv", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (1 KB)", + createdAt: now, + updatedAt: now + }; + session.totalDownloadedBytes = 1024; + session.runStartedAt = now; + + 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.clearAll(); + const snapshot = manager.getSnapshot(); + expect(snapshot.stats.totalPackages).toBe(0); + expect(snapshot.stats.totalFiles).toBe(0); + expect(snapshot.stats.totalDownloaded).toBe(0); + expect(snapshot.session.totalDownloadedBytes).toBe(0); + expect(snapshot.session.runStartedAt).toBe(0); + }); + it("does not start a run when queue is empty", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);