diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 260cb60..278137b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -6193,13 +6193,17 @@ export class DownloadManager extends EventEmitter { } this.ensureUsableDownloadAccount(); this.schedulerGeneration += 1; - + this.session.running = true; + const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => { + const pkg = this.session.packages[packageId]; + return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(packageId)); + })); for (const packageId of this.packagePostProcessTasks.keys()) { this.trackStandalonePackageResult(packageId); } - const recoveredItems = await this.recoverRetryableItems("start"); + const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds); await sleep(0); @@ -11551,7 +11555,10 @@ export class DownloadManager extends EventEmitter { throw new Error(lastError || "Download fehlgeschlagen"); } - private async recoverRetryableItems(trigger: "startup" | "start"): Promise { + private async recoverRetryableItems( + trigger: "startup" | "start", + reactivationPackageIds?: ReadonlySet + ): Promise { let recovered = 0; let finalized = 0; const touchedPackages = new Set(); @@ -11632,7 +11639,11 @@ export class DownloadManager extends EventEmitter { } this.beginPackageResultGeneration(packageId, false, true); if (!this.runPackageIds.has(packageId)) { - this.reactivateStandalonePackageResult(packageId); + if (trigger === "start" && reactivationPackageIds?.has(packageId)) { + this.reactivateStandalonePackageResult(packageId); + } else if (trigger === "startup") { + this.trackStandalonePackageResult(packageId); + } } this.refreshPackageStatus(pkg); } @@ -11828,17 +11839,21 @@ export class DownloadManager extends EventEmitter { } private trackStandalonePackageResult(packageId: string): void { - const key = this.packageResultKey(packageId, this.getPackageResultGeneration(packageId)); - if (this.suppressedPackageResults.has(key)) { + const generation = this.getPackageResultGeneration(packageId); + const key = this.packageResultKey(packageId, generation); + if (this.suppressedPackageResults.has(key) || this.isPackageResultTracked(packageId, generation)) { return; } this.standalonePackageResults.add(key); } private reactivateStandalonePackageResult(packageId: string): void { - const key = this.packageResultKey(packageId, this.getPackageResultGeneration(packageId)); + const generation = this.getPackageResultGeneration(packageId); + const key = this.packageResultKey(packageId, generation); this.suppressedPackageResults.delete(key); - this.standalonePackageResults.add(key); + if (!this.isPackageResultTracked(packageId, generation)) { + this.standalonePackageResults.add(key); + } } private suppressStandalonePackageResults(): void { diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 4606ae9..e5b2e89 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -612,6 +612,79 @@ describe("authoritative run completion", () => { expect(history).toHaveLength(0); }); + it("keeps an earlier run-owned postprocess result alive when a later run is stopped", async () => { + const { manager, session, events, history } = setup({ autoExtractWhenStopped: true }); + const packageA = addPackage(session, ["queued"], "earlier-run-package"); + const state = internal(manager); + vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined); + + await manager.start(); + const packageAItem = session.items[packageA.itemIds[0]]; + packageAItem.status = "completed"; + packageAItem.downloadedBytes = 1_000; + packageAItem.totalBytes = 1_000; + packageAItem.progressPercent = 100; + packageAItem.fullStatus = "Fertig"; + packageA.status = "completed"; + state.runOutcomes.set(packageAItem.id, "completed"); + let releasePostProcess = (): void => {}; + const postProcessGate = new Promise((resolve) => { + releasePostProcess = resolve; + }); + state.handlePackagePostProcessing = vi.fn(async () => postProcessGate); + const packageAPostProcess = state.runPackagePostProcessing(packageA.id); + await Promise.resolve(); + state.finishRun(); + + const packageB = addPackage(session, ["queued"], "later-run-package"); + await manager.start(); + expect(state.runPackageIds).toEqual(new Set([packageB.id])); + manager.stop(); + + releasePostProcess(); + await packageAPostProcess; + await flushNotifications(); + + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); + expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1); + expect(history.map((entry) => entry.name)).toEqual([packageA.name]); + }); + + it("does not reactivate a suppressed foreign package when another start recovers it from disk", async () => { + const { manager, session, events, history } = setup({ autoExtractWhenStopped: true }); + const packageA = addPackage(session, ["queued"], "suppressed-recovery-package"); + const state = internal(manager); + vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined); + + await manager.start(); + manager.stop(); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-recovery-")); + tempDirs.push(recoveryDir); + const recoveredPath = path.join(recoveryDir, "recovered-package.rar"); + fs.writeFileSync(recoveredPath, Buffer.alloc(1_000, 7)); + const packageAItem = session.items[packageA.itemIds[0]]; + packageA.enabled = false; + packageA.status = "failed"; + packageAItem.status = "failed"; + packageAItem.targetPath = recoveredPath; + packageAItem.downloadedBytes = 0; + packageAItem.totalBytes = 1_000; + packageAItem.progressPercent = 0; + packageAItem.fullStatus = "Resume-Link erneuern"; + packageAItem.lastError = "download_underflow"; + + const packageB = addPackage(session, ["queued"], "recovery-run-package"); + await manager.start(); + await Promise.allSettled([...state.packagePostProcessTasks.values()]); + await flushNotifications(); + + expect(state.runPackageIds).toEqual(new Set([packageB.id])); + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0); + expect(history).toHaveLength(0); + manager.stop(); + }); + it("suppresses a stopped postprocess-only generation and allows an explicit package retry", async () => { const { manager, session, events, history } = setup({ autoExtract: true, autoExtractWhenStopped: true }); const pkg = addPackage(session, ["completed"], "postprocess-only-package");