diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a4d66..ee23a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to Multi-Debrid Downloader are documented in this file. ## [Unreleased] +## [2.0.83] - 2026-09-01 + +### Bounded CRC recovery + +- Treat an extractor-confirmed CRC failure as a damaged download even when the file size and archive signature are valid. +- Discard every affected archive part, clear the previous provider assignment, and request a fresh direct link for one clean automatic redownload. +- Persist the one-redownload limit across application restarts so an upstream service repeatedly returning the same damaged content cannot create an endless download loop. +- Clear the recovery limit on a manual item or package reset while continuing to leave valid signed archives untouched for wrong-password failures. +- Keep extraction, password discovery, and extractor fallback behavior unchanged. + ## [2.0.82] - 2026-09-01 ### Startup work folders diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 3d9c805..451c5c4 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -10,12 +10,12 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Verifiziert am: 1. September 2026, Europe/Berlin - Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader` -- Arbeitsbranch: `release/v2.0.82` -- Quellbasis: `release/v2.0.81` +- Arbeitsbranch: `release/v2.0.83` +- Quellbasis: `release/v2.0.82` - Release-Tag: `v2.0.82` -- Baseline-Commit: `d58cfba817bd73d015c3c295e593082becc3211f` -- Hotfix-Basis: `5cee459d0bcc5c19d8d6483948a036a43c9d91b1` -- Paketversion: `2.0.82` +- Baseline-Commit: `f3f2f60ece9322bfaddeb8f47d07830bb8fe273a` +- Hotfix-Basis: `f3f2f60ece9322bfaddeb8f47d07830bb8fe273a` +- Paketversion: `2.0.83` - Letztes Release: `Multi-Debrid-Downloader v2.0.82`, veröffentlicht am 1. September 2026 auf GitHub und Forgejo - Runtime-Voraussetzung: Node.js `>=20`; lokal verifiziert mit Node.js `24.19.0` und npm `11.17.0` @@ -183,6 +183,15 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - „Sitzung“ und „Verbleibend“ stammen weiterhin aus demselben 750-Millisekunden-Snapshot. Bei Restmengen ab einem TiB wurde die sichtbare TB-Präzision von vier auf fünf Nachkommastellen erhöht: Ein Anzeigeschritt sinkt dadurch von rund 105 MiB auf rund 10,5 MiB und bleibt auch bei großen Queues sichtbar im gleichen Takt. - Download-, Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. +## Änderungen in v2.0.83 + +- Die bereitgestellte intakte RAR-Datei und die MDD-Datei besitzen beide exakt 808.969.900 Byte, aber unterschiedliche SHA-256-Werte. UnRAR bestätigt ausschließlich in der MDD-Datei einen Prüfsummenfehler; 8.197 Bytes unterscheiden sich in einem kompakten Bereich ab Byte 106.576. +- Eine direkte Range-Anfrage ohne MDD und ohne Proxy an den von Real-Debrid erzeugten Direktlink lieferte exakt die fehlerhaften Bytes der MDD-Datei. Damit sind Proxy-Segmentierung, lokaler Schreibpfad und Extractor als Erzeuger dieses konkreten Schadens ausgeschlossen; der aktive Real-Debrid-Direktlink selbst lieferte beschädigten Inhalt. +- Der bisherige Recovery-Guard setzte Dateien mit korrekter Größe und gültigem RAR-Kopf trotz eines durch beide Extractor-Pfade bestätigten CRC-Fehlers nicht neu in die Queue. Eine gültige Signatur schützt jedoch nur den Archivkopf und erkennt beschädigte Nutzdaten nicht. +- Ein entsprechend bestätigter CRC-Fehler verwirft nun alle betroffenen Archivteile, setzt Größe und Providerbindung zurück und fordert genau einen vollständigen Neudownload über einen frisch erzeugten Direktlink an. +- Der Einmal-Schutz wird pro Item in der Session gespeichert und überlebt einen Programmneustart. Liefert der Upstream erneut einen CRC-Fehler, bleibt der zweite Fehler sichtbar, statt eine Endlosschleife zu erzeugen. Ein manueller Item- oder Paket-Reset löscht den Schutz und erlaubt einen neuen bewussten Versuch. +- Echte Passwortfehler bei Dateien mit korrekter Größe und gültiger Archivsignatur lösen weiterhin keinen unnötigen Neudownload aus. Die Entpack-, Passwort- und Fallbacklogik selbst wurde nicht verändert. + ## Start-, Build- und Testbefehle ```powershell @@ -214,6 +223,9 @@ npm exec -- tsc --noEmit ## Verifizierungen vom 1. September 2026 +- Kandidat für `v2.0.83`: 392 von 392 fokussierten Download-Manager- und Storage-Tests erfolgreich. Die neuen Regressionen beweisen den einmaligen Neudownload bei CRC trotz gültiger RAR-Signatur, den persistenten Schutz vor einem zweiten automatischen Neudownload, die unveränderte Passwortfehler-Abgrenzung sowie das Löschen des Schutzes beim manuellen Reset. +- Vollständiger Clientlauf des Kandidaten: 143 Testdateien erfolgreich, 1 optionale JVM-Testdatei übersprungen; 2.725 Tests erfolgreich und 4 übersprungen. Nur die zwei bekannten Windows-Symlink-Fixtures endeten mangels Berechtigung vor ihrer Produktassertion mit `EPERM`. Alle 265 Download-Manager- und 84 Extractor-Tests sind vollständig grün. +- TypeScript-Prüfung, Main-Build, Renderer-Produktionsbuild, Node-Self-Check und alle 16 Backup-API-Tests sind erfolgreich. Die bekannte Vite-Warnung betrifft weiterhin den rund 581 KiB großen Renderer-Chunk. - Kandidat für `v2.0.82`: TypeScript-Prüfung und 616 fokussierte Ordner-, Storage-, Renderer-, Einstellungs-, Übersetzungs-, Account- und Downloadansichtstests erfolgreich. Die Ordnerregressionen bestätigen den ausgeschalteten Standard, selektive Zielerstellung, den Erhalt vorhandener Dateien und die Fehlerisolation zwischen Zielen. - Vollständiger Clientlauf: 143 Testdateien erfolgreich, 1 optionale JVM-Testdatei übersprungen; 2.725 Tests erfolgreich und 4 übersprungen. Nur die zwei bekannten Windows-Symlink-Fixtures endeten mangels Berechtigung vor ihrer Produktassertion mit `EPERM`. Die unveränderten 265 Download-Manager- und 84 Entpacktests sind vollständig grün. - Backup-API: 16 von 16 Tests erfolgreich. diff --git a/package-lock.json b/package-lock.json index 5c49056..6948d4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.82", + "version": "2.0.83", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.82", + "version": "2.0.83", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 5dd2885..08800f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.82", + "version": "2.0.83", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 016a0a0..0769c24 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -3701,10 +3701,11 @@ export class DownloadManager extends EventEmitter { item.speedBps = 0; item.downloadedBytes = 0; item.totalBytes = null; - item.progressPercent = 0; - item.resumable = true; - item.attempts = 0; - item.lastError = ""; + item.progressPercent = 0; + item.resumable = true; + item.attempts = 0; + delete item.archiveRecoveryRedownloads; + item.lastError = ""; item.fullStatus = "Wartet"; item.provider = null; item.updatedAt = nowMs(); @@ -6065,10 +6066,11 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; - item.speedBps = 0; - item.attempts = 0; - item.retries = 0; - item.lastError = ""; + item.speedBps = 0; + item.attempts = 0; + item.retries = 0; + delete item.archiveRecoveryRedownloads; + item.lastError = ""; item.resumable = true; item.targetPath = ""; item.provider = null; @@ -6147,10 +6149,11 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; - item.speedBps = 0; - item.attempts = 0; - item.retries = 0; - item.lastError = ""; + item.speedBps = 0; + item.attempts = 0; + item.retries = 0; + delete item.archiveRecoveryRedownloads; + item.lastError = ""; item.resumable = true; item.targetPath = ""; item.provider = null; @@ -7819,44 +7822,66 @@ export class DownloadManager extends EventEmitter { return 0; } - const inspectedArchiveItems = archiveItems - .map((item) => ({ item, state: inspectPackageItemDiskState(pkg, item) })); - const corruptArchiveItems = inspectedArchiveItems - .filter(({ state }) => state.reason !== "ok"); - - if (corruptArchiveItems.length === 0) { - const firstPart = inspectedArchiveItems.find(({ state }) => state.diskPath); - let hasValidSignature = false; - if (firstPart?.state.diskPath) { - try { - const fd = fs.openSync(firstPart.state.diskPath, "r"); - try { - const header = Buffer.alloc(8); - fs.readSync(fd, header, 0, 8, 0); - hasValidSignature = - (header[0] === 0x52 && header[1] === 0x61 && header[2] === 0x72 && header[3] === 0x21 && header[4] === 0x1a && header[5] === 0x07) || - (header[0] === 0x37 && header[1] === 0x7a && header[2] === 0xbc && header[3] === 0xaf) || - (header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04); - } finally { - fs.closeSync(fd); - } - } catch { } - } - - if (hasValidSignature) { - logger.warn( - `Auto-Recovery (${scope}): ${failure.archiveName} uebersprungen - ` + - `Dateien haben korrekte Groesse und gueltige Archiv-Signatur, ` + - `wahrscheinlicher Passwort-/Extractor-Fall statt defektem Download` - ); - return 0; - } - - logger.warn( - `Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber ungueltige Archiv-Signatur, ` + - `erzwinge Re-Download aller ${archiveItems.length} Parts` - ); - corruptArchiveItems.push(...inspectedArchiveItems); + const inspectedArchiveItems = archiveItems + .map((item) => ({ item, state: inspectPackageItemDiskState(pkg, item) })); + const recoveryRedownloads = Math.max( + 0, + ...archiveItems.map((item) => Math.max(0, Math.floor(Number(item.archiveRecoveryRedownloads) || 0))) + ); + if (recoveryRedownloads >= 1) { + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} nicht erneut heruntergeladen - ` + + `automatischer Archiv-Neudownload bereits versucht` + ); + return 0; + } + + const corruptArchiveItems = inspectedArchiveItems + .filter(({ state }) => state.reason !== "ok"); + + if (corruptArchiveItems.length === 0) { + if (failure.category === "crc_error") { + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} meldet CRC-Fehler trotz korrekter Größe und Archiv-Signatur, ` + + `erzwinge einmaligen Neu-Download aller ${archiveItems.length} Parts` + ); + corruptArchiveItems.push(...inspectedArchiveItems); + } + + if (failure.category !== "crc_error") { + const firstPart = inspectedArchiveItems.find(({ state }) => state.diskPath); + let hasValidSignature = false; + if (firstPart?.state.diskPath) { + try { + const fd = fs.openSync(firstPart.state.diskPath, "r"); + try { + const header = Buffer.alloc(8); + fs.readSync(fd, header, 0, 8, 0); + hasValidSignature = + (header[0] === 0x52 && header[1] === 0x61 && header[2] === 0x72 && header[3] === 0x21 && header[4] === 0x1a && header[5] === 0x07) || + (header[0] === 0x37 && header[1] === 0x7a && header[2] === 0xbc && header[3] === 0xaf) || + (header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04); + } finally { + fs.closeSync(fd); + } + } catch { } + } + + if (hasValidSignature) { + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} übersprungen - ` + + `Dateien haben korrekte Größe und gültige Archiv-Signatur, ` + + `wahrscheinlicher Passwort-/Extractor-Fall statt defektem Download` + ); + return 0; + } + + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Größe aber ungültige Archiv-Signatur, ` + + `erzwinge Neu-Download aller ${archiveItems.length} Parts` + ); + corruptArchiveItems.push(...inspectedArchiveItems); + } } const queuedAt = nowMs(); @@ -7872,13 +7897,19 @@ export class DownloadManager extends EventEmitter { } this.releaseTargetPath(item.id); this.dropItemContribution(item.id); - item.targetPath = ""; - item.status = "queued"; - item.attempts = 0; - item.downloadedBytes = 0; - item.progressPercent = 0; - item.speedBps = 0; - item.lastError = failure.errorText; + item.targetPath = ""; + item.status = "queued"; + item.attempts = 0; + item.archiveRecoveryRedownloads = recoveryRedownloads + 1; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + item.provider = null; + item.providerLabel = undefined; + item.providerAccountId = undefined; + item.providerAccountLabel = undefined; + item.lastError = failure.errorText; item.fullStatus = reason; item.updatedAt = queuedAt; changed += 1; diff --git a/src/main/storage.ts b/src/main/storage.ts index 1ff3bef..1626843 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1039,8 +1039,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState { targetPath: asText(item.targetPath), metadataRenameTargetPath: asText(item.metadataRenameTargetPath) || undefined, resumable: item.resumable === undefined ? true : Boolean(item.resumable), - attempts: clampNumber(item.attempts, 0, 0, 10_000), - lastError: asText(item.lastError), + attempts: clampNumber(item.attempts, 0, 0, 10_000), + archiveRecoveryRedownloads: item.archiveRecoveryRedownloads === undefined + ? undefined + : clampNumber(item.archiveRecoveryRedownloads, 0, 0, 1), + lastError: asText(item.lastError), fullStatus: asText(item.fullStatus), onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined, createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER), diff --git a/src/shared/types.ts b/src/shared/types.ts index c2fa0db..505920c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -492,8 +492,9 @@ export interface DownloadItem { targetPath: string; metadataRenameTargetPath?: string; resumable: boolean; - attempts: number; - lastError: string; + attempts: number; + archiveRecoveryRedownloads?: number; + lastError: string; fullStatus: string; createdAt: number; updatedAt: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 5f9c3d9..d596d64 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6392,10 +6392,10 @@ describe("download manager", () => { autoExtract: true }, session, - createStoragePaths(path.join(root, "state")) - ); - - const changed = (manager as any).autoRecoverArchiveCrcFailure( + createStoragePaths(path.join(root, "state")) + ); + + const changed = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]!), { @@ -6513,7 +6513,7 @@ describe("download manager", () => { expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); }); - it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => { + it("requeues archive parts once on CRC error even when size and RAR signature are valid", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -6594,14 +6594,73 @@ describe("download manager", () => { "hybrid" ); - expect(changed).toBe(0); - for (const itemId of itemIds) { - const item = session.items[itemId]!; - expect(item.status).toBe("completed"); - expect(item.targetPath).toContain(".rar"); - expect(item.downloadedBytes).toBe(archiveSize); - } - }); + expect(changed).toBe(2); + for (const itemId of itemIds) { + const item = session.items[itemId]!; + expect(item.status).toBe("queued"); + expect(item.targetPath).toBe(""); + expect(item.downloadedBytes).toBe(0); + expect(item.totalBytes).toBeNull(); + expect(item.archiveRecoveryRedownloads).toBe(1); + expect(item.provider).toBeNull(); + } + + for (const [index, archiveName] of archiveNames.entries()) { + const targetPath = path.join(outputDir, archiveName); + const content = Buffer.alloc(archiveSize, 0); + Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content); + fs.writeFileSync(targetPath, content); + const item = session.items[itemIds[index]!]!; + item.status = "completed"; + item.targetPath = targetPath; + item.downloadedBytes = archiveSize; + item.totalBytes = archiveSize; + item.progressPercent = 100; + item.provider = "realdebrid"; + } + + const repeatedChanged = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + errorText: "Checksum error after redownload", + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "7z-Fehler: CRCERROR" + }, + "full" + ); + + expect(repeatedChanged).toBe(0); + for (const [index, itemId] of itemIds.entries()) { + expect(session.items[itemId]?.status).toBe("completed"); + expect(session.items[itemId]?.archiveRecoveryRedownloads).toBe(1); + expect(fs.existsSync(path.join(outputDir, archiveNames[index]!))).toBe(true); + } + + for (const itemId of itemIds) { + delete session.items[itemId]?.archiveRecoveryRedownloads; + } + const wrongPasswordChanged = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + errorText: "Wrong password", + category: "wrong_password", + suggestRedownload: true, + jvmFailureReason: "Can not open encrypted archive" + }, + "full" + ); + + expect(wrongPasswordChanged).toBe(0); + for (const itemId of itemIds) { + expect(session.items[itemId]?.status).toBe("completed"); + expect(session.items[itemId]?.archiveRecoveryRedownloads).toBeUndefined(); + } + }); it("does not treat rev files as ready archive parts during disk fallback", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -8840,6 +8899,7 @@ describe("download manager", () => { targetPath: path.join(root, "downloads", "reset-extraction", `${itemId}.part1.rar`), resumable: true, attempts: 1, + archiveRecoveryRedownloads: 1, lastError: "Unerwartetes Dateiende", fullStatus: `Entpack-Fehler [${itemId}.part1.rar]: Unerwartetes Dateiende`, onlineStatus: "online", @@ -8876,6 +8936,7 @@ describe("download manager", () => { fullStatus: "Wartet", onlineStatus: "online" })); + expect(snapshot.items[itemId].archiveRecoveryRedownloads).toBeUndefined(); } await manager.resetPackage(packageId); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index b7ac908..8e48e90 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -138,6 +138,7 @@ describe("settings storage", () => { status: "completed", fileName: "deepbrid.bin", targetPath: path.join(outputDir, "deepbrid.bin"), + archiveRecoveryRedownloads: 99, createdAt: 1, updatedAt: 2 } @@ -151,6 +152,7 @@ describe("settings storage", () => { }, 0); expect(session.items["item-deepbrid"]?.provider).toBe("deepbrid"); + expect(session.items["item-deepbrid"]?.archiveRecoveryRedownloads).toBe(1); expect(session.packages["pkg-deepbrid"]?.cleanedProviders).toEqual(["deepbrid"]); expect(history?.provider).toBe("deepbrid"); });