diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index db57975..7941aea 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -12265,9 +12265,27 @@ export class DownloadManager extends EventEmitter { pkg.outputProvenance = []; pkg.cleanupErrorCategory = ""; } + this.pruneFinalizedPackageResults(); return next; } + private pruneFinalizedPackageResults(): void { + const retained = new Set(this.standalonePackageResults); + for (const context of this.runContexts.values()) { + for (const [packageId, generation] of context.packageGenerations) { + retained.add(this.packageResultKey(packageId, generation)); + } + } + for (const pkg of Object.values(this.session.packages)) { + retained.add(this.packageResultKey(pkg.id, this.getPackageResultGeneration(pkg.id))); + } + for (const key of this.finalizedPackageResults.keys()) { + if (!retained.has(key)) { + this.finalizedPackageResults.delete(key); + } + } + } + private createRunContext(packageIds: Iterable, startedAt: number, downloadsFinished: boolean): RunLifecycleContext { const packageGenerations = new Map(); for (const packageId of packageIds) { @@ -12654,12 +12672,8 @@ export class DownloadManager extends EventEmitter { this.queueNotificationEvent(buildRunNotificationEvent(result)); } this.runContexts.delete(context.id); - for (const [packageId, generation] of context.packageGenerations) { - if (!this.session.packages[packageId] && !this.isPackageResultTracked(packageId, generation)) { - this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation)); - } - } } + this.pruneFinalizedPackageResults(); } private refreshPackageStatus(pkg: PackageEntry): void { @@ -13051,11 +13065,18 @@ export class DownloadManager extends EventEmitter { } const completedAt = nowMs(); const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0)); + const itemIds = [...new Set(items.map((item) => item.id))]; + const itemProvenance = items + .map((item) => String(item.targetPath || item.id).replace(/\\/g, "/").toLocaleLowerCase("de-DE")) + .sort(); + const archiveIdentity = itemProvenance.length > 0 + ? itemProvenance.join("|") + : `${progress.archiveName.toLocaleLowerCase("de-DE")}:${Math.max(0, Math.floor(progress.current))}`; const operation: ArchiveOperationMetric = { - id: `${pkg.id}:${progress.archiveName.toLocaleLowerCase("de-DE")}`, + id: `${pkg.id}:${createHash("sha256").update(archiveIdentity).digest("hex").slice(0, 24)}`, name: progress.archiveName, - itemIds: [...new Set(items.map((item) => item.id))], - partCount: Math.max(1, items.length), + itemIds, + partCount: itemIds.length, startedAt: Math.max(0, completedAt - durationMs), completedAt, durationMs, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index d6fed80..91b9d5e 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -15114,6 +15114,65 @@ describe("package priority ordering", () => { }); describe("package lifecycle telemetry boundaries", () => { + it("uses item-path provenance for archive identity and leaves unknown part counts at zero", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-identity-")); + tempDirs.push(root); + const session = emptySession(); + const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { + id: "archive-identity-package", + name: "Archive identity", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + status: "completed" as const, + itemIds: ["item-a", "item-b"], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + const item = (id: string, directory: string) => ({ + id, + packageId: pkg.id, + url: `https://example.test/${id}`, + provider: "realdebrid" as const, + status: "completed" as const, + retries: 0, + speedBps: 0, + downloadedBytes: 1, + totalBytes: 1, + progressPercent: 100, + fileName: "episode.rar", + targetPath: path.join(pkg.outputDir, directory, "episode.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt: 1_000, + updatedAt: 1_000 + }); + const progress = (current: number) => ({ + current, + total: 3, + percent: 100, + archiveName: "episode.rar", + archivePercent: 100, + elapsedMs: 1_000, + archiveDone: true, + archiveSuccess: true + }); + const state = manager as any; + + state.recordArchiveOperation(pkg, progress(0), [item("item-a", "season-a")]); + state.recordArchiveOperation(pkg, progress(1), [item("item-b", "season-b")]); + state.recordArchiveOperation(pkg, { ...progress(2), archiveName: "unresolved.rar" }, []); + + const operations = pkg.archiveOperations || []; + expect(operations).toHaveLength(3); + expect(new Set(operations.map((operation) => operation.id))).toHaveLength(3); + expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]); + }); + it("records queued, slot start and terminal timestamps around real post-processing", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lifecycle-boundaries-")); tempDirs.push(root); diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 002c8e7..fe517b1 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -348,6 +348,30 @@ describe("authoritative package completion", () => { expect(events.map((event) => event.type)).toEqual(["package_completed"]); expect(events[0].payload.title).toContain("Paket-Digest"); }); + + it.each([ + ["digest", 4], + ["individual", 80] + ] as const)("delivers 80 successful packages in %s mode without loss", async (mode, expectedEvents) => { + const { manager, session, events } = setup({ notifyPackageSuccessMode: mode }); + const state = internal(manager); + for (let index = 0; index < 80; index += 1) { + const pkg = addPackage(session, ["completed"], `bulk-package-${String(index).padStart(2, "0")}`); + state.runPackageIds.add(pkg.id); + state.tryFinalizePackageResult(pkg.id); + } + if (mode === "digest") { + state.flushPackageSuccessDigest(); + } + await state.notificationEnqueueChain; + + expect(events).toHaveLength(expectedEvents); + expect(new Set(events.map((event) => event.id))).toHaveLength(expectedEvents); + if (mode === "digest") { + expect(events.map((event) => event.payload.fields.length)).toEqual([20, 20, 20, 20]); + expect(events.every((event) => event.payload.description === "80 Pakete abgeschlossen")).toBe(true); + } + }); }); describe("authoritative run completion", () => { @@ -595,6 +619,7 @@ describe("authoritative run completion", () => { expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true); expect(history).toHaveLength(1); expect(history[0].id).toBe(`hist-${pkg.id}-2`); + expect([...state.finalizedPackageResults.keys()].filter((key) => key.startsWith(`${pkg.id}:`))).toEqual([`${pkg.id}:2`]); }); it("tracks a main postprocess task created by triggerPendingExtractions after start begins", async () => {