Release v1.4.33 with DLC import and stats hotfixes
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-03-01 01:39:51 +01:00
parent ff1036563a
commit edbfba6663
8 changed files with 231 additions and 7 deletions

View File

@ -2,6 +2,36 @@
Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert. 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 ## 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. 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.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.32", "version": "1.4.33",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.32", "version": "1.4.33",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.32", "version": "1.4.33",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -7,6 +7,11 @@ import { ParsedPackageInput } from "../shared/types";
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024; 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 { function decodeDcryptPayload(responseText: string): unknown {
let text = String(responseText || "").trim(); let text = String(responseText || "").trim();
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i); const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
@ -187,30 +192,51 @@ async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> { export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
const out: ParsedPackageInput[] = []; const out: ParsedPackageInput[] = [];
const failures: string[] = [];
let sawDlc = false;
for (const filePath of filePaths) { for (const filePath of filePaths) {
if (path.extname(filePath).toLowerCase() !== ".dlc") { if (path.extname(filePath).toLowerCase() !== ".dlc") {
continue; continue;
} }
sawDlc = true;
let packages: ParsedPackageInput[] = []; let packages: ParsedPackageInput[] = [];
let fileFailed = false;
let fileFailureReasons: string[] = [];
try { try {
packages = await decryptDlcLocal(filePath); packages = await decryptDlcLocal(filePath);
} catch (error) { } catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) { if (isContainerSizeValidationError(error)) {
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
continue; continue;
} }
fileFailed = true;
fileFailureReasons.push(`lokal: ${compactErrorText(error)}`);
packages = []; packages = [];
} }
if (packages.length === 0) { if (packages.length === 0) {
try { try {
packages = await decryptDlcViaDcrypt(filePath); packages = await decryptDlcViaDcrypt(filePath);
} catch (error) { } catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) { if (isContainerSizeValidationError(error)) {
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
continue; continue;
} }
fileFailed = true;
fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`);
packages = []; packages = [];
} }
} }
if (packages.length === 0 && fileFailed) {
failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`);
}
out.push(...packages); 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; return out;
} }

View File

@ -681,6 +681,8 @@ export class DownloadManager extends EventEmitter {
return this.statsCache; return this.statsCache;
} }
this.resetSessionTotalsIfQueueEmpty();
let totalDownloaded = 0; let totalDownloaded = 0;
let totalFiles = 0; let totalFiles = 0;
for (const item of Object.values(this.session.items)) { for (const item of Object.values(this.session.items)) {
@ -714,6 +716,25 @@ export class DownloadManager extends EventEmitter {
return stats; 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 { public renamePackage(packageId: string, newName: string): void {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg) { if (!pkg) {
@ -922,6 +943,7 @@ export class DownloadManager extends EventEmitter {
this.nonResumableActive = 0; this.nonResumableActive = 0;
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.resetSessionTotalsIfQueueEmpty();
this.persistNow(); this.persistNow();
this.emitState(true); this.emitState(true);
} }
@ -1966,6 +1988,7 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed"; pkg.status = "completed";
} }
} }
this.resetSessionTotalsIfQueueEmpty();
this.persistSoon(); this.persistSoon();
} }
@ -2371,6 +2394,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.delete(packageId); this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId); this.runCompletedPackages.delete(packageId);
this.hybridExtractRequeue.delete(packageId); this.hybridExtractRequeue.delete(packageId);
this.resetSessionTotalsIfQueueEmpty();
} }
private async ensureScheduler(): Promise<void> { private async ensureScheduler(): Promise<void> {

View File

@ -703,7 +703,11 @@ export function App(): ReactElement {
if (files.length === 0) { return; } if (files.length === 0) { return; }
await persistDraftSettings(); await persistDraftSettings();
const result = await window.rd.addContainers(files); 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) => { }, (error) => {
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
}); });
@ -721,7 +725,11 @@ export function App(): ReactElement {
await performQuickAction(async () => { await performQuickAction(async () => {
await persistDraftSettings(); await persistDraftSettings();
const result = await window.rd.addContainers(dlc); 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) => { }, (error) => {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
}); });

View File

@ -78,4 +78,43 @@ describe("container", () => {
// Should have tried both! // Should have tried both!
expect(fetchSpy).toHaveBeenCalledTimes(2); 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(`<rc>${Buffer.alloc(16).toString("base64")}</rc>`, { 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);
});
}); });

View File

@ -2399,6 +2399,103 @@ describe("download manager", () => {
expect(summary).toBeNull(); 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 () => { it("does not start a run when queue is empty", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);