diff --git a/src/main/download-completion.ts b/src/main/download-completion.ts index b5f7783..85aa0ee 100644 --- a/src/main/download-completion.ts +++ b/src/main/download-completion.ts @@ -127,6 +127,19 @@ export function validateDownloadedFileCompletion(args: { } if (args.plan.source === "stream-end") { + // H3: Kein Content-Length, keine Provider-Größe UND 0 Bytes empfangen → der + // Hoster hat die Verbindung sofort geschlossen. Das ist ein fehlgeschlagener + // Download, kein gültiges "fertig" — sonst gilt eine leere Datei als komplett + // und es gibt keinen Auto-Redownload. Verhält sich jetzt wie der bereits + // behandelte Fall actualBytes<=0 mit bekannter Größe (oben). + if (actualBytes <= 0) { + return { + ok: false, + totalBytes: 0, + acceptedMetadataMismatch: false, + error: "download_underflow:0/0" + }; + } return { ok: true, totalBytes: actualBytes, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 2fc2969..626a491 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1672,6 +1672,14 @@ export class DownloadManager extends EventEmitter { private packageDeferredPostProcessAbortControllers = new Map(); + // Hybrid post-extract (Rename + MKV-Collect) läuft als detached Promise sobald + // ein Archiv-Set fertig ist. Separat von der Deferred-Pipe getrackt, damit + // runDeferredPostExtraction's replace-Logik die laufende Hybrid-Arbeit nicht + // killt — und damit globaler Abort (Stop/Shutdown) + Run-Abschluss sie sehen. + // Set pro Package, da mehrere Archive-Sets dicht hintereinander fertig werden + // können. (H1/H2/M1) + private packageHybridPostProcessControllers = new Map>(); + private packagePostProcessVersions = new Map(); private hybridExtractRequeue = new Set(); @@ -2478,6 +2486,17 @@ export class DownloadManager extends EventEmitter { } this.packageDeferredPostProcessAbortControllers.delete(packageId); + // Auch laufende Hybrid-Post-Extract-Promises (Rename/MKV-Collect) abbrechen. (H2) + const hybridSet = this.packageHybridPostProcessControllers.get(packageId); + if (hybridSet) { + for (const controller of hybridSet) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + } + this.packageHybridPostProcessControllers.delete(packageId); + } + this.hybridExtractRequeue.delete(packageId); this.clearHybridArchiveState(packageId); } @@ -2781,6 +2800,8 @@ export class DownloadManager extends EventEmitter { this.speedBytesPerPackage.clear(); this.packagePostProcessTasks.clear(); this.packagePostProcessAbortControllers.clear(); + this.packageDeferredPostProcessAbortControllers.clear(); + this.packageHybridPostProcessControllers.clear(); this.hybridExtractRequeue.clear(); this.hybridExtractedPaths.clear(); this.hybridFailedArchives.clear(); @@ -4558,7 +4579,37 @@ export class DownloadManager extends EventEmitter { private hasDeferredPostProcessPending(packageId: string): boolean { const controller = this.packageDeferredPostProcessAbortControllers.get(packageId); - return Boolean(controller && !controller.signal.aborted); + if (controller && !controller.signal.aborted) { + return true; + } + const hybridSet = this.packageHybridPostProcessControllers.get(packageId); + if (hybridSet) { + for (const c of hybridSet) { + if (!c.signal.aborted) { + return true; + } + } + } + return false; + } + + /** M1: True wenn IRGENDWO noch Deferred- oder Hybrid-Post-Processing läuft. + * Verhindert dass der Scheduler/finishRun den Run als beendet meldet und + * State zurücksetzt, während im Hintergrund noch Dateien verschoben werden. */ + private hasAnyDeferredPostProcessPending(): boolean { + for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { + if (!controller.signal.aborted) { + return true; + } + } + for (const hybridSet of this.packageHybridPostProcessControllers.values()) { + for (const c of hybridSet) { + if (!c.signal.aborted) { + return true; + } + } + } + return false; } private async buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set): Promise { @@ -7083,6 +7134,24 @@ export class DownloadManager extends EventEmitter { } } } + + // H1: Globaler Stop/Shutdown/clearAll/external muss AUCH das Deferred-Post- + // Processing (MKV-Move, Cleanup, Rename) und die Hybrid-Promises abbrechen, + // sonst rasen FS-Operationen gegen den Shutdown-Save und schreiben halbe + // Verschiebungen / löschen halbe Archive. Per-Package wird das von + // abortPackagePostProcessing erledigt — hier der globale Sweep. + for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + } + for (const hybridSet of this.packageHybridPostProcessControllers.values()) { + for (const controller of hybridSet) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + } + } } private async acquirePostProcessSlot(packageId: string): Promise { @@ -8151,7 +8220,7 @@ export class DownloadManager extends EventEmitter { // Single-pass queue presence check (saves one full O(n) iteration per tick) const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false }; const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed; - const postProcessComplete = this.packagePostProcessTasks.size === 0; + const postProcessComplete = this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending(); if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) { this.finishRun(); break; @@ -10790,24 +10859,9 @@ export class DownloadManager extends EventEmitter { return ready; } - // Build lookup: pathKey → item status for pending items. - // Also map by filename (resolved against outputDir) so items without - // targetPath (never started) are still found by the disk-fallback check. const packageItems = pkg.itemIds .map((itemId) => this.session.items[itemId]) .filter(Boolean) as DownloadItem[]; - const pendingItemStatus = new Map(); - for (const item of packageItems) { - if (item.status === "completed") { - continue; - } - if (item.targetPath) { - pendingItemStatus.set(pathKey(item.targetPath), item.status); - } - if (item.fileName && pkg.outputDir) { - pendingItemStatus.set(pathKey(path.join(pkg.outputDir, item.fileName)), item.status); - } - } for (const candidate of candidates) { const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); @@ -10848,54 +10902,6 @@ export class DownloadManager extends EventEmitter { logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${nonCompletedCount} Part(s) laut Session ohne completed-Status)`); ready.add(pathKey(candidate)); continue; - - // Disk-fallback: if all parts exist on disk at their full expected size but some - // items lack "completed" status, allow extraction. This handles items that finished - // downloading but whose status was not updated (crash between write and persist). - const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part))); - let allMissingFullOnDisk = true; - for (const part of missingParts) { - try { - const stat = await fs.promises.stat(part); - // Find the item that owns this file to get its expected totalBytes - const ownerItem = this.findItemByDiskPath(pkg, part); - const minBytes = expectedMinBytes(ownerItem?.totalBytes ?? 0, isLargeBinaryLikePath(part)); - if (stat.size < minBytes) { - allMissingFullOnDisk = false; - break; - } - } catch { - allMissingFullOnDisk = false; - break; - } - } - if (!allMissingFullOnDisk) { - continue; - } - // Any non-completed item blocks extraction — failed/cancelled/stopped items may - // have partial files on disk that would corrupt the extraction. - const anyNonCompletedItem = missingParts.some((part) => { - const status = pendingItemStatus.get(pathKey(part)); - return status !== undefined; - }); - if (anyNonCompletedItem) { - continue; - } - // Safety: if any pending item in the package has neither targetPath nor fileName, - // we cannot map it to a file on disk. It could correspond to any missingPart - // (e.g. after a reset before re-unrestrict), so skip disk-fallback for this archive. - const hasUntrackedPendingItem = pkg.itemIds.some((itemId) => { - const pendingItem = this.session.items[itemId]; - return pendingItem - && !isFinishedStatus(pendingItem.status) - && !pendingItem.targetPath - && !pendingItem.fileName; - }); - if (hasUntrackedPendingItem) { - continue; - } - logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${missingParts.length} Part(s) auf Disk ohne completed-Status)`); - ready.add(pathKey(candidate)); } return ready; @@ -11331,16 +11337,37 @@ export class DownloadManager extends EventEmitter { // race with the deferred-post-process pipe's rename / mkvMove for // the same package — without that, hybrid mkvMove could move a // file while deferred rename was still scanning it (ENOENT). + // + // Der Controller wird SYNCHRON (vor dem void-Promise) registriert, damit + // es kein Zeitfenster gibt in dem packagePostProcessTasks leer UND die + // Hybrid-Arbeit ungetrackt ist. shouldAbort stoppt Rename + MKV-Collect + // bei Stop/Shutdown/Cancel/Reset oder wenn das Package ersetzt wurde. (H2) + const hybridController = new AbortController(); + let hybridSet = this.packageHybridPostProcessControllers.get(packageId); + if (!hybridSet) { + hybridSet = new Set(); + this.packageHybridPostProcessControllers.set(packageId, hybridSet); + } + hybridSet.add(hybridController); + const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg; void (async () => { try { - await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg); + await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, hybridShouldAbort); } catch (err) { logger.warn(`Hybrid Auto-Rename Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); } try { - await this.chainPackageFileOp(pkg.id, () => this.collectMkvFilesToLibrary(packageId, pkg)); + await this.chainPackageFileOp(pkg.id, () => this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort)); } catch (err) { logger.warn(`Hybrid MKV-Collection Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); + } finally { + const set = this.packageHybridPostProcessControllers.get(packageId); + if (set) { + set.delete(hybridController); + if (set.size === 0) { + this.packageHybridPostProcessControllers.delete(packageId); + } + } } })(); } @@ -12307,7 +12334,9 @@ export class DownloadManager extends EventEmitter { // Keep runPackageIds and runCompletedPackages alive when post-processing tasks // are still running (autoExtractWhenStopped) so handlePackagePostProcessing() // can still update runCompletedPackages. They are cleared by the next start(). - if (this.packagePostProcessTasks.size === 0) { + // M1: auch laufende Deferred/Hybrid-Arbeit berücksichtigen, sonst werden die + // Run-Maps geleert während noch MKVs verschoben werden. + if (this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending()) { this.runPackageIds.clear(); this.runCompletedPackages.clear(); } diff --git a/tasks/todo.md b/tasks/todo.md index efafff3..c4caa30 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -54,5 +54,17 @@ App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte L --- -## REVIEW / ERGEBNISSE -(wird nach Umsetzung gefüllt) +## REVIEW / ERGEBNISSE (2026-05-23) + +**Umgesetzt (v1.7.158):** +- ✅ **H1** — `abortPostProcessing` aborted jetzt auch alle Deferred- + Hybrid-Controller (globaler Stop/Shutdown/clearAll/external). Keine FS-Race gegen Shutdown-Save mehr. +- ✅ **H2** — Hybrid-Post-Extract läuft über neue `packageHybridPostProcessControllers`-Map (Set pro Package), Controller SYNCHRON vor dem detached Promise registriert, `shouldAbort` an Rename + MKV-Collect durchgereicht. `abortPackagePostProcessing` + `clearAll` räumen die Map. Cancel/Reset stoppt jetzt laufende Hybrid-Arbeit. +- ✅ **M1** — neuer `hasAnyDeferredPostProcessPending()`; Scheduler-Abschluss + `finishRun`-Clear gaten darauf. `hasDeferredPostProcessPending` (per-Package, für package_done-Cleanup) prüft jetzt auch Hybrid. Run endet erst wenn Background-FS-Arbeit fertig. +- ✅ **H3** — `validateDownloadedFileCompletion`: 0-Byte bei `stream-end` → `ok:false` (download_underflow), routet in den bestehenden Retry-Pfad. Regressionstest in `tests/download-completion.test.ts` (8 Tests). +- ✅ **N1** — toter Disk-Fallback-Block in `findReadyArchiveSets` + verwaiste `pendingItemStatus`-Map entfernt (verhaltensneutral). + +**Bewusst NICHT umgesetzt (mit Begründung):** +- ⏭️ **M2** (`blockAllPersistence` nie zurückgesetzt) — der vom Report vorgeschlagene Reset wäre **unsicher**: nach Backup-Import ist die In-Memory-Session stale (Import schreibt nur auf Disk, lädt den Manager nicht neu). Ein Reset würde beim nächsten persist die restored Daten mit Stale-State überschreiben. `blockAllPersistence` ist absichtlich bis-Neustart. Sauberer Fix = In-Memory-Reload nach Import (größerer Umbau, separat). +- ⏭️ **M3** (`cancelPendingAsyncSaves` wartet nicht auf laufenden Save) — Report stuft selbst als reines I/O-Overlap ein; die Generation-Guard (storage.ts:1022) schützt die Datenintegrität bereits (stale Write wird verworfen). Kein Korrektheitsgewinn, daher kein Eingriff. + +**Verifikation:** 30 Test-Dateien, 621 Tests grün. Build sauber. Advisor-Review vor Implementierung (fing H2-Falle: Hybrid-Controller nicht in die Deferred-Map legen, sonst killt `runDeferredPostExtraction` sie selbst). diff --git a/tests/download-completion.test.ts b/tests/download-completion.test.ts new file mode 100644 index 0000000..75f2899 --- /dev/null +++ b/tests/download-completion.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; + +describe("download-completion", () => { + describe("planDownloadCompletion", () => { + it("uses content-length when present", () => { + const plan = planDownloadCompletion({ + existingBytes: 0, responseStatus: 200, contentLength: 1000, + totalFromRange: null, knownTotal: null, correctedTotal: null + }); + expect(plan.source).toBe("content-length"); + expect(plan.expectedTotal).toBe(1000); + }); + it("falls back to stream-end when no size info is available", () => { + const plan = planDownloadCompletion({ + existingBytes: 0, responseStatus: 200, contentLength: 0, + totalFromRange: null, knownTotal: null, correctedTotal: null + }); + expect(plan.source).toBe("stream-end"); + expect(plan.expectedTotal).toBeNull(); + }); + }); + + describe("validateDownloadedFileCompletion", () => { + const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false }; + const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true }); + const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false }); + + // H3 regression: a stream-end download (no Content-Length, no provider size) + // that yielded 0 bytes is a FAILED download, not a valid completion. + it("rejects a 0-byte stream-end download (H3)", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd }); + expect(result.ok).toBe(false); + expect(result.error).toContain("download_underflow"); + }); + + it("accepts a non-empty stream-end download", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd }); + expect(result.ok).toBe(true); + expect(result.totalBytes).toBe(5_000_000); + }); + + it("rejects an underflowing content-length download", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 }); + expect(result.ok).toBe(false); + }); + + it("accepts a complete content-length download", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) }); + expect(result.ok).toBe(true); + }); + + it("rejects a 0-byte download even with known provider size", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) }); + expect(result.ok).toBe(false); + }); + + it("accepts provider-metadata download and flags size mismatch", () => { + const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 }); + // provider-metadata: shorter than expected -> underflow rejected + expect(result.ok).toBe(false); + }); + }); +});