diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 9f6b452..8c343f9 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -466,7 +466,7 @@ type DownloadManagerOptions = { protectEmptyClobber?: boolean; }; -type PendingRunResult = { +type RunLifecycleContext = { id: string; startedAt: number; packageGenerations: Map; @@ -1936,13 +1936,11 @@ export class DownloadManager extends EventEmitter { private historyRecordedPackages = new Set(); - private packageResultGenerations = new Map(); - private finalizedPackageResults = new Map(); - private runPackageGenerations = new Map(); + private runContexts = new Map(); - private pendingRunResult: PendingRunResult | null = null; + private standalonePackageResults = new Set(); private successDigestResults = new Map(); @@ -3122,10 +3120,9 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.historyRecordedPackages.clear(); - this.packageResultGenerations.clear(); this.finalizedPackageResults.clear(); - this.runPackageGenerations.clear(); - this.pendingRunResult = null; + this.runContexts.clear(); + this.standalonePackageResults.clear(); this.successDigestResults.clear(); if (this.successDigestTimer) { clearTimeout(this.successDigestTimer); @@ -3194,6 +3191,7 @@ export class DownloadManager extends EventEmitter { cleanedTotalBytes: 0, cleanedUrls: [], cleanedProviders: [], + resultGeneration: 1, downloadStartedAt: 0, downloadCompletedAt: 0, createdAt: nowMs(), @@ -3240,7 +3238,7 @@ export class DownloadManager extends EventEmitter { if (this.session.running) { this.runItemIds.add(itemId); this.runPackageIds.add(packageId); - this.runPackageGenerations.set(packageId, this.beginPackageResultGeneration(packageId)); + this.beginPackageResultGeneration(packageId); } if (looksLikeOpaqueFilename(fileName)) { const existing = unresolvedByLink.get(link) ?? []; @@ -3971,7 +3969,7 @@ export class DownloadManager extends EventEmitter { }); } - private async directoryHasAnyFiles(rootDir: string): Promise { + private async directoryHasAnyFiles(rootDir: string): Promise { if (!rootDir) { return false; } @@ -4005,10 +4003,39 @@ export class DownloadManager extends EventEmitter { } } } - return false; - } - - private async removeEmptyDirectoryTree(rootDir: string): Promise { + return false; + } + + private async countPackageOutputFiles(rootDir: string): Promise { + if (!rootDir) { + return 0; + } + const stack = [rootDir]; + let count = 0; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && !isArchiveLikePath(fullPath) && !isIgnorableEmptyDirFileName(entry.name)) { + count += 1; + } + } + } + return count; + } + + private async removeEmptyDirectoryTree(rootDir: string): Promise { if (!rootDir) { return 0; } @@ -5288,32 +5315,6 @@ export class DownloadManager extends EventEmitter { return false; } - // Packages whose post-processing (task, deferred pass or hybrid round) is still - // alive must keep their run-membership when the run set is replaced/cleared — - // otherwise their terminal notification is dropped as soon as session.running - // flips false (the notify guard checks running || runPackageIds). - private addTrailingPostProcessPackageIds(target: Set): void { - for (const id of this.packagePostProcessTasks.keys()) { - target.add(id); - } - for (const [id, controller] of this.packageDeferredPostProcessAbortControllers) { - if (!controller.signal.aborted) { - target.add(id); - } - } - for (const [id, tasks] of this.packageHybridPostProcessTasks) { - if (tasks.size > 0) target.add(id); - } - for (const [id, hybridSet] of this.packageHybridPostProcessControllers) { - for (const c of hybridSet) { - if (!c.signal.aborted) { - target.add(id); - break; - } - } - } - } - private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] { const folderCandidates: string[] = []; let currentDir = path.dirname(sourcePath); @@ -5784,7 +5785,6 @@ export class DownloadManager extends EventEmitter { this.runItemIds.add(itemId); } this.runPackageIds.add(packageId); - this.runPackageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); } await Promise.allSettled(postProcessTasks); @@ -5867,7 +5867,6 @@ export class DownloadManager extends EventEmitter { } if (this.session.running) { this.runPackageIds.add(pkgId); - this.runPackageGenerations.set(pkgId, this.getPackageResultGeneration(pkgId)); } } @@ -5990,7 +5989,7 @@ export class DownloadManager extends EventEmitter { if (item.status === "queued" || item.status === "reconnect_wait") { this.runItemIds.add(item.id); this.runPackageIds.add(item.packageId); - this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId)); + this.beginPackageResultGeneration(item.packageId); } } this.persistSoon(); @@ -6011,11 +6010,8 @@ export class DownloadManager extends EventEmitter { this.emitState(true); return; } - this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); - this.runPackageGenerations.clear(); - this.ensureRunPackageGenerations(this.runPackageIds); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6098,7 +6094,7 @@ export class DownloadManager extends EventEmitter { if (item.status === "queued" || item.status === "reconnect_wait") { this.runItemIds.add(item.id); this.runPackageIds.add(item.packageId); - this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId)); + this.beginPackageResultGeneration(item.packageId); } } this.persistSoon(); @@ -6120,11 +6116,8 @@ export class DownloadManager extends EventEmitter { this.emitState(true); return; } - this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); - this.runPackageGenerations.clear(); - this.ensureRunPackageGenerations(this.runPackageIds); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6216,11 +6209,8 @@ export class DownloadManager extends EventEmitter { }); if (runItems.length === 0) { if (this.packagePostProcessTasks.size > 0) { - this.runItemIds.clear(); + this.runItemIds.clear(); this.runPackageIds.clear(); - this.addTrailingPostProcessPackageIds(this.runPackageIds); - this.runPackageGenerations.clear(); - this.ensureRunPackageGenerations(this.runPackageIds); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.session.running = true; @@ -6237,11 +6227,8 @@ export class DownloadManager extends EventEmitter { }); return; } - this.runItemIds.clear(); + this.runItemIds.clear(); this.runPackageIds.clear(); - this.addTrailingPostProcessPackageIds(this.runPackageIds); - this.runPackageGenerations.clear(); - this.ensureRunPackageGenerations(this.runPackageIds); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6270,11 +6257,8 @@ export class DownloadManager extends EventEmitter { this.emitState(true); return; } - this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runItemIds = new Set(runItems.map((item) => item.id)); this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); - this.runPackageGenerations.clear(); - this.ensureRunPackageGenerations(this.runPackageIds); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6371,16 +6355,11 @@ export class DownloadManager extends EventEmitter { pkg.status = "queued"; pkg.updatedAt = nowMs(); } - } + } if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { - const outcomes = Array.from(this.runOutcomes.values()); - const success = outcomes.filter((s) => s === "completed").length; - const failed = outcomes.filter((s) => s === "failed").length; - const cancelled = outcomes.filter((s) => s === "cancelled").length; - this.ensureRunPackageGenerations(this.runPackageIds); - const packageResults = [...this.runPackageGenerations] - .flatMap(([packageId, generation]) => { - const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation)); + const packageResults = [...this.runPackageIds] + .flatMap((packageId) => { + const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId))); return result ? [result] : []; }); this.flushPackageSuccessDigest(); @@ -6390,13 +6369,13 @@ export class DownloadManager extends EventEmitter { startedAt: this.session.runStartedAt, completedAt: nowMs(), packages: packageResults, - totalPackages: this.runPackageIds.size, - successfulFiles: success, - failedFiles: failed, - cancelledFiles: cancelled + totalPackages: this.runPackageIds.size }))); - this.pendingRunResult = null; } + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); this.persistSoon(); this.emitState(true); } @@ -8307,10 +8286,9 @@ export class DownloadManager extends EventEmitter { this.logPackageForPackage(pkg, "INFO", "Extraktion manuell wiederholt", { completedItems: completedItems.length, targetedItems: targetItems.length - }); + }); const generation = this.beginPackageResultGeneration(packageId, false, true); - this.runPackageIds.add(packageId); - this.runPackageGenerations.set(packageId, generation); + this.standalonePackageResults.add(this.packageResultKey(packageId, generation)); this.persistSoon(); this.emitState(true); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); @@ -8338,10 +8316,9 @@ export class DownloadManager extends EventEmitter { this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", { completedItems: completedItems.length, targetedItems: targetItems.length - }); + }); const generation = this.beginPackageResultGeneration(packageId, false, true); - this.runPackageIds.add(packageId); - this.runPackageGenerations.set(packageId, generation); + this.standalonePackageResults.add(this.packageResultKey(packageId, generation)); this.persistSoon(); this.emitState(true); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); @@ -8353,9 +8330,15 @@ export class DownloadManager extends EventEmitter { } } - private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void { - this.notePackageDownloadStarted(pkg, completedAt); - pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt); + private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void { + this.notePackageDownloadStarted(pkg, completedAt); + pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt); + if (pkg.itemIds.every((itemId) => { + const item = this.session.items[itemId]; + return !item || isFinishedStatus(item.status); + })) { + pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, completedAt); + } } private getPackageHistoryDurationSeconds(pkg: PackageEntry): number { @@ -11609,8 +11592,8 @@ export class DownloadManager extends EventEmitter { continue; } const generation = this.beginPackageResultGeneration(packageId, false, true); - if (this.runPackageIds.has(packageId)) { - this.runPackageGenerations.set(packageId, generation); + if (!this.runPackageIds.has(packageId)) { + this.standalonePackageResults.add(this.packageResultKey(packageId, generation)); } this.refreshPackageStatus(pkg); } @@ -11681,21 +11664,26 @@ export class DownloadManager extends EventEmitter { } private getPackageResultGeneration(packageId: string): number { - const existing = this.packageResultGenerations.get(packageId); - if (existing && existing > 0) { - return existing; + const pkg = this.session.packages[packageId]; + if (!pkg) { + return 1; } - this.packageResultGenerations.set(packageId, 1); - return 1; + const generation = Number.isFinite(pkg.resultGeneration) + ? Math.max(1, Math.floor(pkg.resultGeneration || 1)) + : 1; + pkg.resultGeneration = generation; + return generation; } private beginPackageResultGeneration(packageId: string, resetDownloadTelemetry = false, forceReset = false): number { const current = this.getPackageResultGeneration(packageId); const currentKey = this.packageResultKey(packageId, current); - const wasFinalized = this.finalizedPackageResults.has(currentKey); - const next = wasFinalized ? current + 1 : current; - this.packageResultGenerations.set(packageId, next); const pkg = this.session.packages[packageId]; + const wasFinalized = this.finalizedPackageResults.has(currentKey) || (pkg?.terminalAt || 0) > 0; + const next = wasFinalized ? current + 1 : current; + if (pkg) { + pkg.resultGeneration = next; + } if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) { if (resetDownloadTelemetry) { pkg.downloadStartedAt = 0; @@ -11714,14 +11702,27 @@ export class DownloadManager extends EventEmitter { return next; } - private ensureRunPackageGenerations(packageIds: Iterable): void { + private captureRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + const packageGenerations = new Map(); for (const packageId of packageIds) { - if (this.runPackageGenerations.has(packageId)) { - continue; - } - const generation = this.getPackageResultGeneration(packageId); - this.runPackageGenerations.set(packageId, generation); + packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); } + const context: RunLifecycleContext = { id: uuidv4(), startedAt, packageGenerations }; + this.runContexts.set(context.id, context); + return context; + } + + private isPackageResultTracked(packageId: string, generation: number): boolean { + const key = this.packageResultKey(packageId, generation); + if (this.standalonePackageResults.has(key)) { + return true; + } + for (const context of this.runContexts.values()) { + if (context.packageGenerations.get(packageId) === generation) { + return true; + } + } + return false; } private queueNotificationEvent(notification: NotificationEvent): void { @@ -11784,14 +11785,17 @@ export class DownloadManager extends EventEmitter { private tryFinalizePackageResult(packageId: string): PackageResult | null { const pkg = this.session.packages[packageId]; - if (!pkg || (!this.runPackageIds.has(packageId) && !this.runPackageGenerations.has(packageId))) { + if (!pkg) { + return null; + } + const generation = this.getPackageResultGeneration(packageId); + if (!this.runPackageIds.has(packageId) && !this.isPackageResultTracked(packageId, generation)) { return null; } const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; if (items.some((item) => !isFinishedStatus(item.status)) || this.hasPackageLifecycleWork(packageId)) { return null; } - const generation = this.runPackageGenerations.get(packageId) || this.getPackageResultGeneration(packageId); const key = this.packageResultKey(packageId, generation); const existing = this.finalizedPackageResults.get(key); if (existing) { @@ -11839,45 +11843,45 @@ export class DownloadManager extends EventEmitter { } this.persistSoon(); this.emitState(); - this.tryFinalizeRunResult(); + this.standalonePackageResults.delete(key); + this.tryFinalizeRunResults(); return result; } - private tryFinalizeRunResult(): void { - const pending = this.pendingRunResult; - if (!pending) { - return; - } - const packageResults: PackageResult[] = []; - for (const [packageId, generation] of pending.packageGenerations) { - const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation)); - if (!result) { - return; + private tryFinalizeRunResults(): void { + for (const context of [...this.runContexts.values()]) { + const packageResults: PackageResult[] = []; + let complete = true; + for (const [packageId, generation] of context.packageGenerations) { + const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation)); + if (!result) { + complete = false; + break; + } + packageResults.push(result); } - packageResults.push(result); - } - const result = buildRunResult({ - id: pending.id, - stopped: false, - startedAt: pending.startedAt, - completedAt: nowMs(), - packages: packageResults, - totalPackages: pending.packageGenerations.size - }); - this.flushPackageSuccessDigest(result.completedAt); - if (this.settings.notifyOnRunFinished) { - this.queueNotificationEvent(buildRunNotificationEvent(result)); - } - this.pendingRunResult = null; - for (const [packageId, generation] of pending.packageGenerations) { - if (!this.session.packages[packageId]) { - this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation)); - this.packageResultGenerations.delete(packageId); + if (!complete) { + continue; + } + const result = buildRunResult({ + id: context.id, + stopped: false, + startedAt: context.startedAt, + completedAt: nowMs(), + packages: packageResults, + totalPackages: context.packageGenerations.size + }); + this.flushPackageSuccessDigest(result.completedAt); + if (this.settings.notifyOnRunFinished) { + 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.runPackageIds.clear(); - this.runPackageGenerations.clear(); - this.runCompletedPackages.clear(); } private refreshPackageStatus(pkg: PackageEntry): void { @@ -11917,7 +11921,9 @@ export class DownloadManager extends EventEmitter { return; } - pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, nowMs()); + if (!(pkg.downloadEndedAt || 0)) { + pkg.downloadEndedAt = nowMs(); + } if (failed > 0 || extractFailed > 0) { pkg.status = "failed"; } else if (cancelled > 0) { @@ -12570,9 +12576,10 @@ export class DownloadManager extends EventEmitter { this.emitState(); } } - }); - - logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); + }); + pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + + logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", { extracted: result.extracted, failed: result.failed @@ -13125,8 +13132,9 @@ export class DownloadManager extends EventEmitter { } emitExtractStatus(overallLabel); } - }); - logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); + }); + pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", { extracted: result.extracted, failed: result.failed, @@ -13355,8 +13363,9 @@ export class DownloadManager extends EventEmitter { ); } }); - throwIfAborted(); - extractedCount += nestedResult.extracted; + throwIfAborted(); + pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + extractedCount += nestedResult.extracted; logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", { extracted: nestedResult.extracted, @@ -13629,15 +13638,11 @@ export class DownloadManager extends EventEmitter { extracted, durationSeconds: duration, averageSpeedBps: avgSpeed - }; - this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; - this.ensureRunPackageGenerations(this.runPackageIds); - this.pendingRunResult = total > 0 ? { - id: uuidv4(), - startedAt: runStartedAt, - packageGenerations: new Map(this.runPackageGenerations) - } : null; + }; + this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; + const runContext = total > 0 ? this.captureRunContext(this.runPackageIds, runStartedAt) : null; this.runItemIds.clear(); + this.runPackageIds.clear(); this.runOutcomes.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); @@ -13658,10 +13663,10 @@ export class DownloadManager extends EventEmitter { this.lastSettingsPersistAt = 0; this.persistNow(); this.emitState(); - for (const packageId of this.runPackageIds) { + for (const packageId of runContext?.packageGenerations.keys() || []) { this.tryFinalizePackageResult(packageId); } - this.tryFinalizeRunResult(); + this.tryFinalizeRunResults(); } public getSessionStats(): import("../shared/types").SessionStats { diff --git a/src/main/storage.ts b/src/main/storage.ts index 69cf2f0..7cf0638 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -998,6 +998,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState { remuxOperations: normalizeRemuxOperations(pkg.remuxOperations), outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000), cleanupErrorCategory: asText(pkg.cleanupErrorCategory), + resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) }; diff --git a/src/shared/types.ts b/src/shared/types.ts index c9a0f82..1c63b4a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -572,6 +572,7 @@ export interface PackageEntry { remuxOperations?: RemuxOperationMetric[]; outputCount?: number; cleanupErrorCategory?: string; + resultGeneration?: number; createdAt: number; updatedAt: number; } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 408e7f3..5175b0d 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -4455,9 +4455,10 @@ describe("download manager", () => { return originalFetch(input, init); }; - let releaseBlockedPostProcess: ((value?: void | PromiseLike) => void) | undefined; - try { - const manager = new DownloadManager( + let releaseBlockedPostProcess: ((value?: void | PromiseLike) => void) | undefined; + try { + const history: HistoryEntry[] = []; + const manager = new DownloadManager( { ...defaultSettings(), token: "rd-token", @@ -4466,14 +4467,15 @@ describe("download manager", () => { autoExtract: false, maxParallel: 1 }, - emptySession(), - createStoragePaths(path.join(root, "state")) + emptySession(), + createStoragePaths(path.join(root, "state")), + { onHistoryEntry: (entry) => history.push(entry) } ); - const blocker = new Promise((resolve) => { - releaseBlockedPostProcess = resolve; - }); - (manager as unknown as { packagePostProcessQueue: Promise }).packagePostProcessQueue = blocker; + const blocker = new Promise((resolve) => { + releaseBlockedPostProcess = resolve; + }); + (manager as any).handlePackagePostProcessing = vi.fn(async () => blocker); manager.addPackages([ { name: "first", links: ["https://dummy/first"] }, @@ -4489,19 +4491,31 @@ describe("download manager", () => { manager.start(); await waitFor(() => manager.getSnapshot().session.items[firstItem]?.status === "completed", 12000); - await waitFor(() => { - const state = manager.getSnapshot().session.items[secondItem]?.status; - return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed"; - }, 6000); + await waitFor(() => { + const state = manager.getSnapshot().session.items[secondItem]?.status; + return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed"; + }, 6000); + + const pendingPackage = manager.getSnapshot().session.packages[firstPackage]; + expect(pendingPackage?.downloadStartedAt).toBeGreaterThan(0); + expect(pendingPackage?.downloadEndedAt).toBeGreaterThanOrEqual(pendingPackage?.downloadStartedAt || 0); + expect(pendingPackage?.downloadEndedAt).toBe(pendingPackage?.downloadCompletedAt); + const downloadEndedAt = pendingPackage?.downloadEndedAt || 0; + await new Promise((resolve) => setTimeout(resolve, 1_200)); + expect(manager.getSnapshot().session.packages[firstPackage]?.downloadEndedAt).toBe(downloadEndedAt); if (releaseBlockedPostProcess) { releaseBlockedPostProcess(); - } - await waitFor(() => !manager.getSnapshot().session.running, 25000); - - const done = manager.getSnapshot(); - expect(done.session.items[firstItem]?.status).toBe("completed"); - expect(done.session.items[secondItem]?.status).toBe("completed"); + } + await waitFor(() => !manager.getSnapshot().session.running, 25000); + await waitFor(() => history.some((entry) => entry.name === "first"), 6000); + + const done = manager.getSnapshot(); + expect(done.session.items[firstItem]?.status).toBe("completed"); + expect(done.session.items[secondItem]?.status).toBe("completed"); + const firstHistory = history.find((entry) => entry.name === "first"); + expect(firstHistory?.downloadEndedAt).toBe(downloadEndedAt); + expect(firstHistory?.totalDurationSeconds).toBeGreaterThan(firstHistory?.downloadDurationSeconds || 0); } finally { if (releaseBlockedPostProcess) { releaseBlockedPostProcess(); @@ -11055,6 +11069,7 @@ describe("download manager", () => { errorCategory: "" }); expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0); + expect(snapshot.session.packages[packageId]?.outputCount).toBe(1); }, 30000); it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => { diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 71fd7c3..dbd8b39 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -243,6 +243,51 @@ describe("authoritative package completion", () => { expect(history).toHaveLength(2); }); + it("continues the persisted result generation after an extraction retry following restart", async () => { + const { manager, session, events, history } = setup(); + const pkg = addPackage(session); + const state = internal(manager); + pkg.resultGeneration = 7; + pkg.terminalAt = Date.now() - 1_000; + pkg.archiveOperations = [{ + id: "archive-restart-failed", + name: "episode.rar", + itemIds: [...pkg.itemIds], + partCount: 1, + startedAt: 10_000, + completedAt: 12_000, + durationMs: 2_000, + status: "failed", + errorCategory: "crc_error" + }]; + session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error"; + vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined); + + manager.retryExtraction(pkg.id); + expect(pkg.resultGeneration).toBe(8); + + pkg.archiveOperations = [{ + id: "archive-restart-completed", + name: "episode.rar", + itemIds: [...pkg.itemIds], + partCount: 1, + startedAt: 20_000, + completedAt: 23_000, + durationMs: 3_000, + status: "completed", + errorCategory: "" + }]; + session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (3.0s)"; + pkg.status = "completed"; + state.tryFinalizePackageResult(pkg.id); + await flushNotifications(); + + expect(events).toHaveLength(1); + expect(events[0].id).toContain(":8:"); + expect(history).toHaveLength(1); + expect(history[0].id).toBe(`hist-${pkg.id}-8`); + }); + it("moves a pending success digest into the outbox before shutdown", async () => { const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" }); const pkg = addPackage(session); @@ -279,6 +324,29 @@ describe("authoritative run completion", () => { expect(events.some((event) => event.type === "run_completed")).toBe(false); }); + it("builds run_stopped file counters only from finalized package results", async () => { + const { manager, session, events } = setup(); + const pkg = addPackage(session); + const state = internal(manager); + state.runPackageIds = new Set([pkg.id]); + state.tryFinalizePackageResult(pkg.id); + await flushNotifications(); + events.length = 0; + + session.running = true; + session.runStartedAt = Date.now() - 10_000; + state.runItemIds = new Set(pkg.itemIds); + state.runPackageIds = new Set([pkg.id]); + state.runOutcomes = new Map([[pkg.itemIds[0], "failed"]]); + + manager.stop(); + await flushNotifications(); + + const stopped = events.find((event) => event.type === "run_stopped"); + expect(stopped).toBeDefined(); + expect(stopped?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true); + }); + it("waits for failed extraction package results before emitting the final run summary", async () => { const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" }); const pkg = addPackage(session); @@ -344,8 +412,6 @@ describe("authoritative run completion", () => { session.runStartedAt = Date.now() - 20_000; state.runItemIds = new Set(pkg.itemIds); state.runPackageIds = new Set([pkg.id]); - state.packageResultGenerations = new Map([[pkg.id, 1]]); - state.runPackageGenerations = new Map([[pkg.id, 1]]); state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]); state.tryFinalizePackageResult(pkg.id); @@ -357,4 +423,36 @@ describe("authoritative run completion", () => { expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]); expect(events[0].payload.title).toContain("Paket-Digest"); }); + + it("finalizes overlapping runs independently when the earlier run finishes deferred work last", async () => { + const { manager, session, events, history } = setup(); + const packageA = addPackage(session, ["completed"], "package-a"); + const packageB = addPackage(session, ["completed"], "package-b"); + const state = internal(manager); + + session.running = true; + session.runStartedAt = Date.now() - 20_000; + state.runItemIds = new Set(packageA.itemIds); + state.runPackageIds = new Set([packageA.id]); + state.runOutcomes = new Map([[packageA.itemIds[0], "completed"]]); + state.packageDeferredPostProcessTasks.set(packageA.id, new Set([Promise.resolve()])); + state.finishRun(); + + session.running = true; + session.runStartedAt = Date.now() - 5_000; + state.runItemIds = new Set(packageB.itemIds); + state.runPackageIds = new Set([packageB.id]); + state.runOutcomes = new Map([[packageB.itemIds[0], "completed"]]); + state.finishRun(); + + state.packageDeferredPostProcessTasks.delete(packageA.id); + state.tryFinalizePackageResult(packageA.id); + await flushNotifications(); + + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(2); + expect(events.filter((event) => event.type === "run_completed")).toHaveLength(2); + expect(new Set(events.filter((event) => event.type === "package_completed").map((event) => event.id)).size).toBe(2); + expect(new Set(events.filter((event) => event.type === "run_completed").map((event) => event.id)).size).toBe(2); + expect(history.map((entry) => entry.name).sort()).toEqual([packageA.name, packageB.name].sort()); + }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index c832261..0893295 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1137,6 +1137,7 @@ describe("settings storage", () => { itemIds: [], cancelled: false, enabled: true, + resultGeneration: 7, downloadStartedAt: 1_000, downloadCompletedAt: 10_000, downloadEndedAt: 12_000, @@ -1174,6 +1175,7 @@ describe("settings storage", () => { }); expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({ + resultGeneration: 7, downloadEndedAt: 12_000, postProcessQueuedAt: 13_000, postProcessStartedAt: 14_000,