From 03da04b6293e64ca132dbf32f6682bb898a28729 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 05:08:48 +0200 Subject: [PATCH] feat(notifications): finalize package and run results --- src/main/app-controller.ts | 7 +- src/main/download-manager.ts | 783 ++++++++++++++------ src/main/notification-events.ts | 322 ++++++++ src/renderer/views/history/HistoryView.tsx | 57 +- src/renderer/views/history/history-model.ts | 57 +- src/renderer/views/history/history.css | 82 +- tests/download-manager.test.ts | 101 ++- tests/history-view.test.tsx | 108 ++- tests/notify-hooks.test.ts | 513 +++++++++---- 9 files changed, 1614 insertions(+), 416 deletions(-) create mode 100644 src/main/notification-events.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 46eda7f..93bfe1a 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -182,8 +182,9 @@ export class AppController { allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), - invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), + invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), protectEmptyClobber: loadResult.status === "empty-unreadable", + enqueueNotification: (event) => this.notificationOutbox.enqueue(event), onHistoryEntry: (entry: HistoryEntry) => { this.recordHistoryEntry(entry); } @@ -1295,6 +1296,10 @@ export class AppController { stopDebugServer(); abortActiveUpdateDownload(); cancelPendingAsyncSaves(); + const notificationFlush = this.manager.flushNotificationsForShutdown?.(); + if (notificationFlush) { + await notificationFlush; + } await this.notificationOutbox.drainForShutdown(3000).catch((error) => { logger.warn(`Notification-Outbox konnte beim Beenden nicht geleert werden: ${String(error)}`); }); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index b2a84d6..9f6b452 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -13,10 +13,12 @@ import { DownloadSummary, DownloadStatus, DuplicatePolicy, - HistoryEntry, - PackageEntry, - PackagePriority, - ParsedPackageInput, + HistoryEntry, + ArchiveOperationMetric, + PackageEntry, + PackagePriority, + PackageResult, + ParsedPackageInput, SessionState, StatisticsLedger, StartConflictEntry, @@ -59,12 +61,11 @@ function releaseTlsSkip(): void { import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; -import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; +import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor"; -import { sendNotification } from "./notify"; -import { logger } from "./logger"; +import { logger } from "./logger"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import { createAccountRuntimeEntries } from "./account-runtime-snapshot"; import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace"; @@ -90,6 +91,16 @@ import { saveStatisticsLedger, seedStatisticsDayProviderBytes } from "./statistics-ledger"; +import { finalizePackageResult } from "./package-telemetry"; +import type { NotificationEvent } from "./notification-outbox"; +import { + buildHistoryEntry, + buildPackageDigestEvents, + buildPackageNotificationEvent, + buildRunNotificationEvent, + buildRunResult, + type PackageResultEnvelope +} from "./notification-events"; type ActiveTask = { itemId: string; @@ -444,15 +455,22 @@ function retryLimitToMaxRetries(retryLimit: number): number { type HistoryEntryCallback = (entry: HistoryEntry) => void; -type DownloadManagerOptions = { +type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; allDebridWebUnrestrict?: AllDebridWebUnrestrictor; realDebridWebUnrestrict?: RealDebridWebUnrestrictor; bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; invalidateMegaSession?: () => void; - onHistoryEntry?: HistoryEntryCallback; - protectEmptyClobber?: boolean; -}; + onHistoryEntry?: HistoryEntryCallback; + enqueueNotification?: (event: NotificationEvent) => Promise; + protectEmptyClobber?: boolean; +}; + +type PendingRunResult = { + id: string; + startedAt: number; + packageGenerations: Map; +}; function generateHistoryId(): string { return `hist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; @@ -1914,11 +1932,23 @@ export class DownloadManager extends EventEmitter { private runOutcomes = new Map(); - private runCompletedPackages = new Set(); - - private historyRecordedPackages = new Set(); - - private notifiedPackages = new Set(); + private runCompletedPackages = new Set(); + + private historyRecordedPackages = new Set(); + + private packageResultGenerations = new Map(); + + private finalizedPackageResults = new Map(); + + private runPackageGenerations = new Map(); + + private pendingRunResult: PendingRunResult | null = null; + + private successDigestResults = new Map(); + + private successDigestTimer: NodeJS.Timeout | null = null; + + private notificationEnqueueChain: Promise = Promise.resolve(); private itemCount = 0; @@ -1961,7 +1991,9 @@ export class DownloadManager extends EventEmitter { private lastStaleResetAt = 0; - private onHistoryEntryCallback?: HistoryEntryCallback; + private onHistoryEntryCallback?: HistoryEntryCallback; + + private enqueueNotificationCallback?: (event: NotificationEvent) => Promise; public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); @@ -1989,8 +2021,9 @@ export class DownloadManager extends EventEmitter { realDebridWebUnrestrict: options.realDebridWebUnrestrict, bestDebridWebUnrestrict: options.bestDebridWebUnrestrict }); - this.invalidateMegaSessionFn = options.invalidateMegaSession; - this.onHistoryEntryCallback = options.onHistoryEntry; + this.invalidateMegaSessionFn = options.invalidateMegaSession; + this.onHistoryEntryCallback = options.onHistoryEntry; + this.enqueueNotificationCallback = options.enqueueNotification; logger.info(`DownloadManager Init: ${Object.keys(this.session.packages).length} Pakete, ${this.itemCount} Items, cleanupPolicy=${this.settings.completedCleanupPolicy}`); for (const pkg of Object.values(this.session.packages)) { this.ensurePackageLogForPackage(pkg); @@ -3086,10 +3119,18 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; this.runItemIds.clear(); this.runPackageIds.clear(); - this.runOutcomes.clear(); - this.runCompletedPackages.clear(); - this.historyRecordedPackages.clear(); - this.notifiedPackages.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.historyRecordedPackages.clear(); + this.packageResultGenerations.clear(); + this.finalizedPackageResults.clear(); + this.runPackageGenerations.clear(); + this.pendingRunResult = null; + this.successDigestResults.clear(); + if (this.successDigestTimer) { + clearTimeout(this.successDigestTimer); + this.successDigestTimer = null; + } this.retryAfterByItem.clear(); this.providerStartReservations.clear(); this.pacedStartReservationByItem.clear(); @@ -3196,10 +3237,11 @@ export class DownloadManager extends EventEmitter { packageEntry.itemIds.push(itemId); this.session.items[itemId] = item; this.itemCount += 1; - if (this.session.running) { - this.runItemIds.add(itemId); - this.runPackageIds.add(packageId); - } + if (this.session.running) { + this.runItemIds.add(itemId); + this.runPackageIds.add(packageId); + this.runPackageGenerations.set(packageId, this.beginPackageResultGeneration(packageId)); + } if (looksLikeOpaqueFilename(fileName)) { const existing = unresolvedByLink.get(link) ?? []; existing.push(itemId); @@ -4396,11 +4438,12 @@ export class DownloadManager extends EventEmitter { const previous = this.packageFileOpChain.get(pkgId); const result = (previous ?? Promise.resolve()).catch(() => undefined).then(fn); this.packageFileOpChain.set(pkgId, result); - return result.finally(() => { - if (this.packageFileOpChain.get(pkgId) === result) { - this.packageFileOpChain.delete(pkgId); - } - }); + return result.finally(() => { + if (this.packageFileOpChain.get(pkgId) === result) { + this.packageFileOpChain.delete(pkgId); + } + this.tryFinalizePackageResult(pkgId); + }); } private async autoRenameExtractedVideoFiles( @@ -4511,12 +4554,13 @@ export class DownloadManager extends EventEmitter { const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag"; let processed = 0; - let failed = 0; - for (const sourcePath of targets) { + let failed = 0; + for (const sourcePath of targets) { if (shouldAbort?.() || signal?.aborted) { return processed; } const sourceName = path.basename(sourcePath); + const remuxStartedAt = nowMs(); let result: VideoProcessResult | null = null; let remuxLease: DiskReservationLease | null = null; try { @@ -4551,6 +4595,18 @@ export class DownloadManager extends EventEmitter { result = { action: "error", reason: "exception", error: "Unbekannter Remux-Status" }; } if (result.action === "aborted") { + if (pkg) { + const completedAt = nowMs(); + pkg.remuxOperations = [...(pkg.remuxOperations || []), { + id: uuidv4(), + fileName: sourceName, + startedAt: remuxStartedAt, + completedAt, + durationMs: Math.max(0, completedAt - remuxStartedAt), + status: "cancelled", + errorCategory: "aborted" + }]; + } return processed; } const langs = (result.audioLanguages || []).join(","); @@ -4592,10 +4648,28 @@ export class DownloadManager extends EventEmitter { // Only strip ".DL." once the file is confirmed German-only (remuxed) or // already single-track. Skips/errors leave the file fully untouched so the // unprocessed state stays visible. - if (result.action === "remuxed" || result.action === "kept-single") { - await this.stripDualLangFromFileName(sourcePath, pkg); - } - } + if (result.action === "remuxed" || result.action === "kept-single") { + await this.stripDualLangFromFileName(sourcePath, pkg); + } + if (pkg) { + const completedAt = nowMs(); + const failedOperation = result.action === "error" || result.action === "skipped-no-space"; + const errorCategory = result.action === "skipped-no-space" + ? "disk_full" + : failedOperation + ? compactErrorText(result.error || result.reason || "remux").slice(0, 256) + : ""; + pkg.remuxOperations = [...(pkg.remuxOperations || []), { + id: uuidv4(), + fileName: result.action === "remuxed" || result.action === "kept-single" ? stripDualLangMarker(sourceName) : sourceName, + startedAt: remuxStartedAt, + completedAt, + durationMs: Math.max(0, completedAt - remuxStartedAt), + status: failedOperation ? "failed" : "completed", + errorCategory + }]; + } + } writeSummary(); logger.info(`Tonspur-Bereinigung fertig: ${processed} verarbeitet, ${failed} Fehler von ${targets.length} Kandidaten in ${extractDir}`); if (pkg) { @@ -5701,17 +5775,16 @@ export class DownloadManager extends EventEmitter { pkg.cleanedTotalBytes = 0; pkg.cleanedUrls = []; pkg.cleanedProviders = []; - pkg.downloadStartedAt = 0; - pkg.downloadCompletedAt = 0; + this.beginPackageResultGeneration(packageId, true); pkg.updatedAt = nowMs(); this.historyRecordedPackages.delete(packageId); - this.notifiedPackages.delete(packageId); - - if (this.session.running) { + + if (this.session.running) { for (const itemId of itemIds) { this.runItemIds.add(itemId); - } - this.runPackageIds.add(packageId); + } + this.runPackageIds.add(packageId); + this.runPackageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); } await Promise.allSettled(postProcessTasks); @@ -5779,21 +5852,22 @@ export class DownloadManager extends EventEmitter { for (const pkgId of affectedPackageIds) { for (const task of this.abortPackagePostProcessing(pkgId, "reset")) postProcessTasks.add(task); - this.runCompletedPackages.delete(pkgId); - this.historyRecordedPackages.delete(pkgId); - this.notifiedPackages.delete(pkgId); - + this.runCompletedPackages.delete(pkgId); + this.historyRecordedPackages.delete(pkgId); + const pkg = this.session.packages[pkgId]; if (pkg) { pkg.cancelled = false; pkg.postProcessLabel = undefined; pkg.audioStripSummary = undefined; pkg.downloadCompletedAt = 0; + this.beginPackageResultGeneration(pkgId, false, true); this.refreshPackageStatus(pkg); pkg.updatedAt = nowMs(); } - if (this.session.running) { - this.runPackageIds.add(pkgId); + if (this.session.running) { + this.runPackageIds.add(pkgId); + this.runPackageGenerations.set(pkgId, this.getPackageResultGeneration(pkgId)); } } @@ -5914,8 +5988,9 @@ export class DownloadManager extends EventEmitter { for (const item of Object.values(this.session.items)) { if (!targetSet.has(item.packageId)) continue; if (item.status === "queued" || item.status === "reconnect_wait") { - this.runItemIds.add(item.id); - this.runPackageIds.add(item.packageId); + this.runItemIds.add(item.id); + this.runPackageIds.add(item.packageId); + this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId)); } } this.persistSoon(); @@ -5937,8 +6012,10 @@ export class DownloadManager extends EventEmitter { return; } this.runItemIds = new Set(runItems.map((item) => item.id)); - this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + 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(); @@ -6019,8 +6096,9 @@ export class DownloadManager extends EventEmitter { const pkg = this.session.packages[item.packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) continue; if (item.status === "queued" || item.status === "reconnect_wait") { - this.runItemIds.add(item.id); - this.runPackageIds.add(item.packageId); + this.runItemIds.add(item.id); + this.runPackageIds.add(item.packageId); + this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId)); } } this.persistSoon(); @@ -6043,8 +6121,10 @@ export class DownloadManager extends EventEmitter { return; } this.runItemIds = new Set(runItems.map((item) => item.id)); - this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + 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(); @@ -6137,8 +6217,10 @@ export class DownloadManager extends EventEmitter { if (runItems.length === 0) { if (this.packagePostProcessTasks.size > 0) { this.runItemIds.clear(); - this.runPackageIds.clear(); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runPackageIds.clear(); + this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runPackageGenerations.clear(); + this.ensureRunPackageGenerations(this.runPackageIds); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.session.running = true; @@ -6156,8 +6238,10 @@ export class DownloadManager extends EventEmitter { return; } this.runItemIds.clear(); - this.runPackageIds.clear(); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runPackageIds.clear(); + this.addTrailingPostProcessPackageIds(this.runPackageIds); + this.runPackageGenerations.clear(); + this.ensureRunPackageGenerations(this.runPackageIds); this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); @@ -6187,8 +6271,10 @@ export class DownloadManager extends EventEmitter { return; } this.runItemIds = new Set(runItems.map((item) => item.id)); - this.runPackageIds = new Set(runItems.map((item) => item.packageId)); - this.addTrailingPostProcessPackageIds(this.runPackageIds); + 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(); @@ -6286,19 +6372,31 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); } } - // A manual stop ends the run without ever reaching finishRun (the scheduler - // loop exits at its while condition), so the run summary would be lost. - // Suppressed for the restart/shutdown path: the process is about to die. - 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; - void sendNotification(this.settings.notifyUrl, { - title: "⏹️ Durchlauf gestoppt", - message: `${success}/${this.runItemIds.size} erfolgreich, ${failed} fehlgeschlagen — Rest zurueck in der Warteschlange`, - mention: this.settings.notifyMention - }); - } + 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)); + return result ? [result] : []; + }); + this.flushPackageSuccessDigest(); + this.queueNotificationEvent(buildRunNotificationEvent(buildRunResult({ + id: uuidv4(), + stopped: true, + startedAt: this.session.runStartedAt, + completedAt: nowMs(), + packages: packageResults, + totalPackages: this.runPackageIds.size, + successfulFiles: success, + failedFiles: failed, + cancelledFiles: cancelled + }))); + this.pendingRunResult = null; + } this.persistSoon(); this.emitState(true); } @@ -7898,22 +7996,32 @@ export class DownloadManager extends EventEmitter { next.resolve(); } - private runPackagePostProcessing(packageId: string): Promise { + private runPackagePostProcessing(packageId: string): Promise { const existing = this.packagePostProcessTasks.get(packageId); if (existing) { this.hybridExtractRequeue.add(packageId); return existing; } - const abortController = new AbortController(); - this.packagePostProcessAbortControllers.set(packageId, abortController); + const abortController = new AbortController(); + this.packagePostProcessAbortControllers.set(packageId, abortController); + const queuedPackage = this.session.packages[packageId]; + if (queuedPackage) { + queuedPackage.postProcessQueuedAt = queuedPackage.postProcessQueuedAt || nowMs(); + queuedPackage.updatedAt = nowMs(); + } // Holder so the task's own finally can identity-check itself (the task Promise // cannot reference its own const inside its initializer). Assigned right after. const handle: { task?: Promise } = {}; const task = (async () => { - const slotWaitStart = nowMs(); - await this.acquirePostProcessSlot(packageId); + const slotWaitStart = nowMs(); + await this.acquirePostProcessSlot(packageId); + const startedPackage = this.session.packages[packageId]; + if (startedPackage) { + startedPackage.postProcessStartedAt = startedPackage.postProcessStartedAt || nowMs(); + startedPackage.updatedAt = nowMs(); + } const slotWaitMs = nowMs() - slotWaitStart; if (slotWaitMs > 100) { logger.info(`Post-Process Slot erhalten nach ${(slotWaitMs / 1000).toFixed(1)}s Wartezeit: pkg=${packageId.slice(0, 8)}`); @@ -7969,11 +8077,13 @@ export class DownloadManager extends EventEmitter { } this.persistSoon(); this.emitState(); - if (this.hybridExtractRequeue.delete(packageId)) { - void this.runPackagePostProcessing(packageId).catch((err) => - logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`) - ); - } + if (this.hybridExtractRequeue.delete(packageId)) { + void this.runPackagePostProcessing(packageId).catch((err) => + logger.warn(`runPackagePostProcessing Fehler (hybridRequeue): ${compactErrorText(err)}`) + ); + } else { + this.tryFinalizePackageResult(packageId); + } } })(); @@ -8198,11 +8308,9 @@ export class DownloadManager extends EventEmitter { completedItems: completedItems.length, targetedItems: targetItems.length }); - // Fresh outcome must notify again (the corrective checkmark after a notified - // extraction failure); unconditional run-membership so the notify guard also - // passes when the retry happens after the run already ended. - this.notifiedPackages.delete(packageId); - this.runPackageIds.add(packageId); + const generation = this.beginPackageResultGeneration(packageId, false, true); + this.runPackageIds.add(packageId); + this.runPackageGenerations.set(packageId, generation); this.persistSoon(); this.emitState(true); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); @@ -8231,8 +8339,9 @@ export class DownloadManager extends EventEmitter { completedItems: completedItems.length, targetedItems: targetItems.length }); - this.notifiedPackages.delete(packageId); - this.runPackageIds.add(packageId); + const generation = this.beginPackageResultGeneration(packageId, false, true); + this.runPackageIds.add(packageId); + this.runPackageGenerations.set(packageId, generation); this.persistSoon(); this.emitState(true); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); @@ -8330,10 +8439,9 @@ export class DownloadManager extends EventEmitter { }; this.onHistoryEntryCallback(entry); } - } - this.historyRecordedPackages.delete(packageId); - this.notifiedPackages.delete(packageId); - this.abortPackagePostProcessing(packageId, "package_removed"); + } + this.historyRecordedPackages.delete(packageId); + this.abortPackagePostProcessing(packageId, "package_removed"); for (const itemId of itemIds) { this.retryAfterByItem.delete(itemId); this.retryStateByItem.delete(itemId); @@ -8342,9 +8450,9 @@ export class DownloadManager extends EventEmitter { delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); } - delete this.session.packages[packageId]; - this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); - this.runCompletedPackages.delete(packageId); + delete this.session.packages[packageId]; + this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); + this.runCompletedPackages.delete(packageId); this.resetSessionTotalsIfQueueEmpty(); } @@ -8528,6 +8636,11 @@ export class DownloadManager extends EventEmitter { return false; } + public async flushNotificationsForShutdown(): Promise { + this.flushPackageSuccessDigest(); + await this.notificationEnqueueChain; + } + private updateStatisticsActivity(now: number): void { if (this.statisticsActivityAt > 0 && this.statisticsActivityWasActive && now > this.statisticsActivityAt) { addStatisticsActiveIntervalInPlace(this.statisticsLedger, this.statisticsActivityAt, now); @@ -9450,8 +9563,9 @@ export class DownloadManager extends EventEmitter { if (active.nonResumableCounted) { this.nonResumableActive = Math.max(0, this.nonResumableActive - 1); } - this.activeTasks.delete(itemId); - this.persistSoon(); + this.activeTasks.delete(itemId); + this.tryFinalizePackageResult(packageId); + this.persistSoon(); this.emitState(); }); } @@ -11494,11 +11608,11 @@ export class DownloadManager extends EventEmitter { if (!pkg) { continue; } - // The package gets a fresh chance — its next outcome must notify again - // (a recovery success after a notified failure is the message the user - // most wants). History dedup stays untouched (separate semantics). - this.notifiedPackages.delete(packageId); - this.refreshPackageStatus(pkg); + const generation = this.beginPackageResultGeneration(packageId, false, true); + if (this.runPackageIds.has(packageId)) { + this.runPackageGenerations.set(packageId, generation); + } + this.refreshPackageStatus(pkg); } logger.warn( `Auto-Retry-Recovery (${trigger}): ${recovered} Item(s) wieder in Queue gesetzt, ` + @@ -11562,39 +11676,209 @@ export class DownloadManager extends EventEmitter { return /\b0\s*B\b/i.test(item.fullStatus || ""); } - // Once per package and run; trailing post-processing after run-end still - // notifies (runPackageIds keeps the id), startup recovery does not (set empty). - private notifyPackageOutcome(pkg: PackageEntry, kind: "completed" | "failed", detail: string): void { - const url = String(this.settings.notifyUrl || "").trim(); - if (!url) { - return; - } - if (kind === "completed" && !this.settings.notifyOnPackageCompleted) { - return; - } - if (kind === "failed" && !this.settings.notifyOnPackageFailed) { - return; - } - if (!this.session.running && !this.runPackageIds.has(pkg.id)) { - return; - } - if (this.notifiedPackages.has(pkg.id)) { - return; - } - this.notifiedPackages.add(pkg.id); - // Release the dedup marker if the delivery ultimately failed (after the - // sender's own retries), so a later manual re-run can notify again instead - // of a transient outage permanently consuming the once-per-package slot. - void sendNotification(url, { - title: kind === "completed" ? "✅ Paket fertig" : "❌ Paket fehlgeschlagen", - message: `${pkg.name}\n${detail}`, - mention: this.settings.notifyMention - }).then((ok) => { - if (!ok) { - this.notifiedPackages.delete(pkg.id); - } - }); - } + private packageResultKey(packageId: string, generation: number): string { + return `${packageId}:${generation}`; + } + + private getPackageResultGeneration(packageId: string): number { + const existing = this.packageResultGenerations.get(packageId); + if (existing && existing > 0) { + return existing; + } + this.packageResultGenerations.set(packageId, 1); + return 1; + } + + 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]; + if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) { + if (resetDownloadTelemetry) { + pkg.downloadStartedAt = 0; + pkg.downloadCompletedAt = 0; + pkg.downloadEndedAt = 0; + } + pkg.postProcessQueuedAt = 0; + pkg.postProcessStartedAt = 0; + pkg.postProcessCompletedAt = 0; + pkg.terminalAt = 0; + pkg.archiveOperations = []; + pkg.remuxOperations = []; + pkg.outputCount = 0; + pkg.cleanupErrorCategory = ""; + } + return next; + } + + private ensureRunPackageGenerations(packageIds: Iterable): void { + for (const packageId of packageIds) { + if (this.runPackageGenerations.has(packageId)) { + continue; + } + const generation = this.getPackageResultGeneration(packageId); + this.runPackageGenerations.set(packageId, generation); + } + } + + private queueNotificationEvent(notification: NotificationEvent): void { + if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) { + return; + } + this.notificationEnqueueChain = this.notificationEnqueueChain + .then(() => this.enqueueNotificationCallback?.(notification)) + .then(() => undefined) + .catch((error) => { + logger.warn(`Notification konnte nicht eingereiht werden: ${compactErrorText(error)}`); + }); + } + + private queueSuccessfulPackageResult(envelope: PackageResultEnvelope): void { + const key = this.packageResultKey(envelope.result.packageId, envelope.generation); + this.successDigestResults.set(key, envelope); + if (this.successDigestTimer) { + return; + } + this.successDigestTimer = setTimeout(() => { + this.successDigestTimer = null; + this.flushPackageSuccessDigest(); + }, 2 * 60 * 1000); + this.successDigestTimer.unref?.(); + } + + private flushPackageSuccessDigest(createdAt = nowMs()): void { + if (this.successDigestTimer) { + clearTimeout(this.successDigestTimer); + this.successDigestTimer = null; + } + if (this.successDigestResults.size === 0) { + return; + } + const envelopes = [...this.successDigestResults.values()]; + this.successDigestResults.clear(); + for (const notification of buildPackageDigestEvents(envelopes, createdAt)) { + this.queueNotificationEvent(notification); + } + } + + private getPackageHistoryProvider(pkg: PackageEntry, items: DownloadItem[]): DebridProvider | null { + const providers = [...new Set([ + ...(pkg.cleanedProviders || []), + ...items.map((item) => item.provider).filter(Boolean) as DebridProvider[] + ])]; + return providers.length === 1 ? providers[0] : null; + } + + private hasPackageLifecycleWork(packageId: string): boolean { + if ([...this.activeTasks.values()].some((task) => task.packageId === packageId)) { + return true; + } + return this.packagePostProcessTasks.has(packageId) + || this.hasDeferredPostProcessPending(packageId) + || (this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0 + || this.packageFileOpChain.has(packageId); + } + + private tryFinalizePackageResult(packageId: string): PackageResult | null { + const pkg = this.session.packages[packageId]; + if (!pkg || (!this.runPackageIds.has(packageId) && !this.runPackageGenerations.has(packageId))) { + 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) { + return existing; + } + const completedAt = nowMs(); + if (!(pkg.downloadEndedAt || 0)) { + pkg.downloadEndedAt = completedAt; + } + if (!(pkg.postProcessStartedAt || 0) && (pkg.postProcessQueuedAt || 0) > 0) { + pkg.postProcessStartedAt = pkg.postProcessQueuedAt; + } + pkg.postProcessCompletedAt = completedAt; + pkg.terminalAt = completedAt; + const result = finalizePackageResult({ + package: pkg, + items, + archiveOperations: pkg.archiveOperations, + remuxOperations: pkg.remuxOperations, + outputCount: pkg.outputCount, + cleanupErrorCategory: pkg.cleanupErrorCategory + }); + this.finalizedPackageResults.set(key, result); + pkg.status = result.status === "partial" ? "failed" : result.status; + pkg.updatedAt = completedAt; + if (this.onHistoryEntryCallback) { + this.onHistoryEntryCallback(buildHistoryEntry(result, { + generation, + outputDir: pkg.outputDir, + urls: [...new Set([...(pkg.cleanedUrls || []), ...items.map((item) => item.url).filter(Boolean)])], + provider: this.getPackageHistoryProvider(pkg, items) + })); + } + const envelope = { generation, result }; + if (result.status === "completed") { + if (this.settings.notifyOnPackageCompleted) { + if (this.settings.notifyPackageSuccessMode === "individual") { + this.queueNotificationEvent(buildPackageNotificationEvent(envelope, completedAt)); + } else { + this.queueSuccessfulPackageResult(envelope); + } + } + } else if (this.settings.notifyOnPackageFailed) { + this.queueNotificationEvent(buildPackageNotificationEvent(envelope, completedAt)); + } + this.persistSoon(); + this.emitState(); + this.tryFinalizeRunResult(); + 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; + } + 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); + } + } + this.runPackageIds.clear(); + this.runPackageGenerations.clear(); + this.runCompletedPackages.clear(); + } private refreshPackageStatus(pkg: PackageEntry): void { let pending = 0; @@ -11627,34 +11911,23 @@ export class DownloadManager extends EventEmitter { return; } - if (pending > 0) { - pkg.status = pkg.enabled ? "queued" : "paused"; - pkg.updatedAt = nowMs(); - return; - } - - const prevStatus = pkg.status; + if (pending > 0) { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + return; + } + + pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, nowMs()); if (failed > 0 || extractFailed > 0) { pkg.status = "failed"; } else if (cancelled > 0) { pkg.status = success > 0 ? "completed" : "cancelled"; } else if (success > 0) { pkg.status = "completed"; - } - pkg.updatedAt = nowMs(); - // A package whose LAST terminal event is a failure never (re-)enters - // post-processing (its trigger sits only on completion paths), so the - // post-process notify hook can't fire — cover every failed-transition here. - // That includes mixed packages (success > 0): the dedup set prevents a - // double-fire if post-processing does run later. - if (pkg.status === "failed" && prevStatus !== "failed") { - this.notifyPackageOutcome(pkg, "failed", `${failed + extractFailed} von ${total} Datei(en) fehlgeschlagen`); - if (success > 0) { - const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - this.recordPackageHistory(pkg.id, pkg, items); - } - } - } + } + pkg.updatedAt = nowMs(); + this.tryFinalizePackageResult(pkg.id); + } private cachedSpeedLimitKbps = 0; @@ -11935,7 +12208,39 @@ export class DownloadManager extends EventEmitter { return false; } - private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise { + private recordArchiveOperation( + pkg: PackageEntry, + progress: ExtractProgressUpdate, + items: DownloadItem[], + errorCategory = "" + ): void { + if (!progress.archiveName || progress.archiveDone !== true) { + return; + } + const completedAt = nowMs(); + const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0)); + const operation: ArchiveOperationMetric = { + id: `${pkg.id}:${progress.archiveName.toLocaleLowerCase("de-DE")}`, + name: progress.archiveName, + itemIds: [...new Set(items.map((item) => item.id))], + partCount: Math.max(1, items.length), + startedAt: Math.max(0, completedAt - durationMs), + completedAt, + durationMs, + status: progress.archiveSuccess === false ? "failed" : "completed", + errorCategory: progress.archiveSuccess === false ? (errorCategory || "extract") : "" + }; + const operations = [...(pkg.archiveOperations || [])]; + const existingIndex = operations.findIndex((entry) => entry.id === operation.id); + if (existingIndex >= 0) { + operations[existingIndex] = operation; + } else { + operations.push(operation); + } + pkg.archiveOperations = operations; + } + + private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise { const completedForDeobfuscation = items.filter((item) => item.status === "completed"); await this.deobfuscateArchiveFiles(pkg, completedForDeobfuscation, signal); if (signal?.aborted) return 0; @@ -12070,8 +12375,9 @@ export class DownloadManager extends EventEmitter { readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey)); } - const autoRecoveredArchives = new Set(); - const failedArchiveErrors = new Map(); + const autoRecoveredArchives = new Set(); + const failedArchiveErrors = new Map(); + const failedArchiveCategories = new Map(); const hybridResolvedItems = new Map(); const hybridStartTimes = new Map(); let hybridLastEmitAt = 0; @@ -12122,8 +12428,9 @@ export class DownloadManager extends EventEmitter { maxParallel: this.settings.maxParallelExtract || 2, extractCpuPriority: "high", onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message), - onArchiveFailure: (failure) => { - const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase()); + onArchiveFailure: (failure) => { + failedArchiveCategories.set(String(failure.archiveName || "").toLowerCase(), failure.category); + const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase()); if (failedArchiveKey) { failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"); } @@ -12179,16 +12486,22 @@ export class DownloadManager extends EventEmitter { } const archItems = hybridResolvedItems.get(progress.archiveName) || []; - if (archiveFinished) { - const doneAt = nowMs(); + if (archiveFinished) { + const doneAt = nowMs(); const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; const doneLabel = progress.archiveSuccess === false ? "Entpacken - Error" : formatExtractDone(doneAt - startedAt); const archiveKey = readyArchiveKeyByName.get(progress.archiveName.toLowerCase()); - if (archiveKey && progress.archiveSuccess !== false) { - this.clearHybridArchiveState(packageId, archiveKey); - } + if (archiveKey && progress.archiveSuccess !== false) { + this.clearHybridArchiveState(packageId, archiveKey); + } + this.recordArchiveOperation( + pkg, + progress, + archItems, + failedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" + ); for (const entry of archItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; entry.fullStatus = doneLabel; @@ -12320,6 +12633,7 @@ export class DownloadManager extends EventEmitter { if (tasks?.size === 0) { this.packageHybridPostProcessTasks.delete(packageId); } + this.tryFinalizePackageResult(packageId); } })(); hybridHandle.task = hybridTask; @@ -12601,8 +12915,9 @@ export class DownloadManager extends EventEmitter { } }, extractTimeoutMs); try { - const autoRecoveredArchives = new Set(); - const fullFailedArchiveErrors = new Map(); + const autoRecoveredArchives = new Set(); + const fullFailedArchiveErrors = new Map(); + const fullFailedArchiveCategories = new Map(); const fullResolvedItems = new Map(); const fullStartTimes = new Map(); let fullLastProgressCurrent: number | null = null; @@ -12684,15 +12999,17 @@ export class DownloadManager extends EventEmitter { maxParallel: this.settings.maxParallelExtract || 2, extractCpuPriority: "high", onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message), - onArchiveFailure: (failure) => { - if (autoRecoveredArchives.has(failure.archiveName)) { + onArchiveFailure: (failure) => { + fullFailedArchiveCategories.set(failure.archiveName.toLowerCase(), failure.category); + if (autoRecoveredArchives.has(failure.archiveName)) { return; } const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full"); if (changed > 0) { - autoRecoveredArchives.add(failure.archiveName); - fullFailedArchiveErrors.delete(failure.archiveName); - return; + autoRecoveredArchives.add(failure.archiveName); + fullFailedArchiveErrors.delete(failure.archiveName); + fullFailedArchiveCategories.delete(failure.archiveName.toLowerCase()); + return; } fullFailedArchiveErrors.set( failure.archiveName, @@ -12739,12 +13056,18 @@ export class DownloadManager extends EventEmitter { } const archiveItems = fullResolvedItems.get(progress.archiveName) || []; - if (archiveFinished) { + if (archiveFinished) { const doneAt = nowMs(); const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; - const doneLabel = progress.archiveSuccess === false - ? "Entpacken - Error" - : formatExtractDone(doneAt - startedAt); + const doneLabel = progress.archiveSuccess === false + ? "Entpacken - Error" + : formatExtractDone(doneAt - startedAt); + this.recordArchiveOperation( + pkg, + progress, + archiveItems, + fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" + ); for (const entry of archiveItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; entry.fullStatus = doneLabel; @@ -12924,17 +13247,7 @@ export class DownloadManager extends EventEmitter { pkg.postProcessLabel = undefined; pkg.updatedAt = nowMs(); - if (pkg.status === "completed") { - this.notifyPackageOutcome(pkg, "completed", `${success} Datei(en)${extractedCount > 0 ? `, ${extractedCount} entpackt` : ""}`); - } else if (pkg.status === "failed") { - this.notifyPackageOutcome(pkg, "failed", `${failed} von ${success + failed + cancelled} Datei(en) fehlgeschlagen`); - } - - if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) { - this.recordPackageHistory(packageId, pkg, items); - } - - if (this.runPackageIds.has(packageId)) { + if (this.runPackageIds.has(packageId)) { if (pkg.status === "completed" || pkg.status === "failed") { this.runCompletedPackages.add(packageId); } else { @@ -12969,6 +13282,8 @@ export class DownloadManager extends EventEmitter { if (tasks?.size === 0) { this.packageDeferredPostProcessTasks.delete(packageId); } + this.tryFinalizePackageResult(packageId); + this.applyPackageDoneCleanup(packageId); }); const tasks = this.packageDeferredPostProcessTasks.get(packageId) || new Set>(); tasks.add(task); @@ -13007,12 +13322,14 @@ export class DownloadManager extends EventEmitter { if (nestedCandidates.length > 0) { pkg.postProcessLabel = "Nested Entpacken..."; this.emitState(); - logger.info(`Deferred Nested-Extraction: ${nestedCandidates.length} Archive in ${pkg.extractDir}`); + logger.info(`Deferred Nested-Extraction: ${nestedCandidates.length} Archive in ${pkg.extractDir}`); this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction gestartet", { nestedCandidates: nestedCandidates.length, extractDir: pkg.extractDir }); - const nestedResult = await extractPackageArchives({ + const nestedFailureCategories = new Map(); + const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + const nestedResult = await extractPackageArchives({ packageDir: pkg.extractDir, targetDir: pkg.extractDir, cleanupMode: this.settings.cleanupMode, @@ -13024,9 +13341,20 @@ export class DownloadManager extends EventEmitter { packageId, onlyArchives: new Set(nestedCandidates.map((p) => process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p))), maxParallel: this.settings.maxParallelExtract || 2, - extractCpuPriority: this.settings.extractCpuPriority, - onLog: (level, message) => this.logPackageForPackage(pkg, level, `Nested-Extractor: ${message}`), - }); + extractCpuPriority: this.settings.extractCpuPriority, + onLog: (level, message) => this.logPackageForPackage(pkg, level, `Nested-Extractor: ${message}`), + onArchiveFailure: (failure) => { + nestedFailureCategories.set(failure.archiveName.toLowerCase(), failure.category); + }, + onProgress: (progress) => { + this.recordArchiveOperation( + pkg, + progress, + resolveArchiveItemsFromList(progress.archiveName, nestedItems), + nestedFailureCategories.get(progress.archiveName.toLowerCase()) || "" + ); + } + }); throwIfAborted(); extractedCount += nestedResult.extracted; logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); @@ -13126,8 +13454,7 @@ export class DownloadManager extends EventEmitter { this.persistSoon(); this.emitState(); - this.applyPackageDoneCleanup(packageId); - } catch (error) { + } catch (error) { const reason = compactErrorText(error); if (reason.includes("aborted:deferred") || reason.includes("deferred_replaced") @@ -13138,9 +13465,10 @@ export class DownloadManager extends EventEmitter { || reason === "skip" || reason === "package_toggle") { logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`); - } else { - logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`); - } + } else { + pkg.cleanupErrorCategory = reason.slice(0, 256) || "cleanup"; + logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`); + } } finally { if (this.packageDeferredPostProcessAbortControllers.get(packageId) === deferredController) { this.packageDeferredPostProcessAbortControllers.delete(packageId); @@ -13279,8 +13607,9 @@ export class DownloadManager extends EventEmitter { } } - private finishRun(): void { - const runStartedAt = this.session.runStartedAt; + private finishRun(): void { + const runStartedAt = this.session.runStartedAt; + const completedAt = nowMs(); this.session.running = false; this.session.paused = false; this.session.runStartedAt = 0; @@ -13290,7 +13619,7 @@ export class DownloadManager extends EventEmitter { const failed = outcomes.filter((status) => status === "failed").length; const cancelled = outcomes.filter((status) => status === "cancelled").length; const extracted = this.runCompletedPackages.size; - const duration = runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - runStartedAt) / 1000)) : 1; + const duration = runStartedAt > 0 ? Math.max(1, Math.floor((completedAt - runStartedAt) / 1000)) : 1; const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration); this.summary = { total, @@ -13302,24 +13631,14 @@ export class DownloadManager extends EventEmitter { averageSpeedBps: avgSpeed }; this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; - if (this.settings.notifyOnRunFinished && total > 0) { - // With autoExtractWhenStopped (default) the run ends as soon as downloads - // are done while extraction may still be running — say so instead of - // claiming the whole run is finished. - const postProcessPending = this.packagePostProcessTasks.size > 0 || this.hasAnyDeferredPostProcessPending(); - const scope = postProcessPending ? "Downloads beendet" : "Durchlauf beendet"; - void sendNotification(this.settings.notifyUrl, { - title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`, - message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`, - mention: this.settings.notifyMention - }); - } - this.runItemIds.clear(); - this.runOutcomes.clear(); - if (this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending()) { - this.runPackageIds.clear(); - this.runCompletedPackages.clear(); - } + this.ensureRunPackageGenerations(this.runPackageIds); + this.pendingRunResult = total > 0 ? { + id: uuidv4(), + startedAt: runStartedAt, + packageGenerations: new Map(this.runPackageGenerations) + } : null; + this.runItemIds.clear(); + this.runOutcomes.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); this.pacedStartReservationByItem.clear(); @@ -13337,9 +13656,13 @@ export class DownloadManager extends EventEmitter { this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.lastSettingsPersistAt = 0; - this.persistNow(); - this.emitState(); - } + this.persistNow(); + this.emitState(); + for (const packageId of this.runPackageIds) { + this.tryFinalizePackageResult(packageId); + } + this.tryFinalizeRunResult(); + } public getSessionStats(): import("../shared/types").SessionStats { const now = nowMs(); diff --git a/src/main/notification-events.ts b/src/main/notification-events.ts new file mode 100644 index 0000000..6822276 --- /dev/null +++ b/src/main/notification-events.ts @@ -0,0 +1,322 @@ +import { createHash } from "node:crypto"; +import type { + DebridProvider, + HistoryEntry, + PackageResult, + PackageResultStatus +} from "../shared/types"; +import type { + NotificationEvent, + NotificationEventType, + NotificationPriority +} from "./notification-outbox"; + +export interface PackageResultEnvelope { + generation: number; + result: PackageResult; +} + +export interface RunResult { + id: string; + stopped: boolean; + startedAt: number; + completedAt: number; + totalDurationSeconds: number; + totalPackages: number; + completedPackages: number; + partialPackages: number; + failedPackages: number; + cancelledPackages: number; + successfulFiles: number; + failedFiles: number; + cancelledFiles: number; + totalBytes: number; + downloadedBytes: number; + averageDownloadSpeedBps: number; + downloadDurationSeconds: number; + extractionDurationSeconds: number; + remuxDurationSeconds: number; + postProcessDurationSeconds: number; + extractionFailures: number; + remuxFailures: number; + downloadFailures: number; + offlineFailures: number; +} + +export interface RunResultInput { + id: string; + stopped: boolean; + startedAt: number; + completedAt: number; + packages: readonly PackageResult[]; + totalPackages?: number; + successfulFiles?: number; + failedFiles?: number; + cancelledFiles?: number; +} + +export interface HistoryEntryContext { + generation: number; + outputDir: string; + urls: string[]; + provider: DebridProvider | null; +} + +const SUCCESS_TTL_MS = 6 * 60 * 60 * 1000; +const IMPORTANT_TTL_MS = 24 * 60 * 60 * 1000; +const DIGEST_PACKAGE_LIMIT = 20; + +const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }); + +function finiteNonNegative(value: unknown): number { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; +} + +function formatBytes(bytes: number): string { + let value = finiteNonNegative(bytes); + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + return `${numberFormatter.format(value)} ${units[index]}`; +} + +function formatDuration(seconds: number): string { + const total = Math.max(0, Math.floor(finiteNonNegative(seconds))); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const remainder = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}` + : `${minutes}:${String(remainder).padStart(2, "0")}`; +} + +function statusLabel(status: PackageResultStatus): string { + if (status === "completed") return "Abgeschlossen"; + if (status === "partial") return "Teilweise abgeschlossen"; + if (status === "failed") return "Fehlgeschlagen"; + return "Abgebrochen"; +} + +function failurePhaseLabel(result: PackageResult): string { + if (result.failurePhase === "download") return "Download"; + if (result.failurePhase === "extract") return "Entpacken"; + if (result.failurePhase === "remux") return "Remux"; + if (result.failurePhase === "cleanup") return "Aufräumen"; + return "—"; +} + +function event( + id: string, + type: NotificationEventType, + priority: NotificationPriority, + createdAt: number, + title: string, + description: string, + color: number, + fields: NotificationEvent["payload"]["fields"] +): NotificationEvent { + return { + id, + type, + priority, + createdAt, + expiresAt: createdAt + (priority === "success" ? SUCCESS_TTL_MS : IMPORTANT_TTL_MS), + attempts: 0, + nextAttemptAt: createdAt, + payload: { title, description, color, fields } + }; +} + +function packageEventType(status: PackageResultStatus): NotificationEventType { + if (status === "completed") return "package_completed"; + if (status === "partial") return "package_partial"; + return "package_failed"; +} + +export function buildPackageNotificationEvent( + envelope: PackageResultEnvelope, + createdAt: number +): NotificationEvent { + const { generation, result } = envelope; + const type = packageEventType(result.status); + const priority: NotificationPriority = result.status === "completed" ? "success" : "error"; + const title = result.status === "completed" + ? "✅ Paket fertig" + : result.status === "partial" + ? "⚠️ Paket teilweise fertig" + : result.status === "cancelled" + ? "⏹️ Paket abgebrochen" + : "❌ Paket fehlgeschlagen"; + const fields = [ + { name: "Paket", value: result.name || "—", inline: false }, + { name: "Ergebnis", value: statusLabel(result.status), inline: true }, + { name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false }, + { name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true }, + { name: "Zeiten", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false }, + { name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true }, + { name: "Archive", value: `${result.archiveCount} Gruppen · ${result.partCount} Parts · ${result.outputCount} Ausgaben`, inline: true } + ]; + if (result.failurePhase) { + fields.push({ + name: "Fehler", + value: `${failurePhaseLabel(result)}${result.errorCategory ? ` · ${result.errorCategory.slice(0, 256)}` : ""}`, + inline: false + }); + } + return event( + `package:${result.packageId}:${generation}:${type}`, + type, + priority, + createdAt, + title, + result.name, + priority === "success" ? 0x2ecc71 : 0xe74c3c, + fields + ); +} + +export function buildPackageDigestEvents( + envelopes: readonly PackageResultEnvelope[], + createdAt: number +): NotificationEvent[] { + const sorted = [...envelopes].sort((left, right) => { + const byCompleted = left.result.completedAt - right.result.completedAt; + if (byCompleted !== 0) return byCompleted; + const byPackage = left.result.packageId.localeCompare(right.result.packageId); + return byPackage !== 0 ? byPackage : left.generation - right.generation; + }); + const digestKey = createHash("sha256") + .update(sorted.map((entry) => `${entry.result.packageId}:${entry.generation}`).join("|")) + .digest("hex") + .slice(0, 16); + const events: NotificationEvent[] = []; + for (let offset = 0; offset < sorted.length; offset += DIGEST_PACKAGE_LIMIT) { + const chunk = sorted.slice(offset, offset + DIGEST_PACKAGE_LIMIT); + const page = Math.floor(offset / DIGEST_PACKAGE_LIMIT) + 1; + const totalPages = Math.ceil(sorted.length / DIGEST_PACKAGE_LIMIT); + const fields = chunk.map(({ result }) => ({ + name: result.name || "Paket", + value: `${result.successfulFiles} Dateien · ${formatBytes(result.downloadedBytes)} · ${formatDuration(result.totalDurationSeconds)}`, + inline: false + })); + events.push(event( + `package-digest:${digestKey}:${page}`, + "package_completed", + "success", + createdAt, + totalPages > 1 ? `✅ Paket-Digest ${page}/${totalPages}` : "✅ Paket-Digest", + `${sorted.length} Pakete abgeschlossen`, + 0x2ecc71, + fields + )); + } + return events; +} + +export function buildRunResult(input: RunResultInput): RunResult { + const packages = [...input.packages]; + const sum = (select: (result: PackageResult) => number): number => packages.reduce((total, result) => total + finiteNonNegative(select(result)), 0); + const downloadedBytes = sum((result) => result.downloadedBytes); + const downloadDurationSeconds = sum((result) => result.downloadDurationSeconds); + const successfulFiles = input.successfulFiles ?? sum((result) => result.successfulFiles); + const failedFiles = input.failedFiles ?? sum((result) => result.failedFiles); + const cancelledFiles = input.cancelledFiles ?? sum((result) => result.cancelledFiles); + return { + id: input.id, + stopped: input.stopped, + startedAt: finiteNonNegative(input.startedAt), + completedAt: finiteNonNegative(input.completedAt), + totalDurationSeconds: Math.max(0, Math.floor((finiteNonNegative(input.completedAt) - finiteNonNegative(input.startedAt)) / 1000)), + totalPackages: Math.max(packages.length, Math.floor(finiteNonNegative(input.totalPackages))), + completedPackages: packages.filter((result) => result.status === "completed").length, + partialPackages: packages.filter((result) => result.status === "partial").length, + failedPackages: packages.filter((result) => result.status === "failed").length, + cancelledPackages: packages.filter((result) => result.status === "cancelled").length, + successfulFiles, + failedFiles, + cancelledFiles, + totalBytes: sum((result) => result.totalBytes), + downloadedBytes, + averageDownloadSpeedBps: downloadDurationSeconds > 0 ? Math.floor(downloadedBytes / downloadDurationSeconds) : 0, + downloadDurationSeconds, + extractionDurationSeconds: sum((result) => result.extractionDurationSeconds), + remuxDurationSeconds: sum((result) => result.remuxDurationSeconds), + postProcessDurationSeconds: sum((result) => result.postProcessDurationSeconds), + extractionFailures: packages.reduce((total, result) => total + result.archiveOperations.filter((operation) => operation.status === "failed").length, 0), + remuxFailures: packages.reduce((total, result) => total + result.remuxOperations.filter((operation) => operation.status === "failed").length, 0), + downloadFailures: packages.filter((result) => result.failurePhase === "download").reduce((total, result) => total + result.failedFiles, 0), + offlineFailures: packages.filter((result) => /offline|not found|nicht gefunden/i.test(result.errorCategory)).reduce((total, result) => total + result.failedFiles, 0) + }; +} + +export function buildRunNotificationEvent(result: RunResult): NotificationEvent { + const priority: NotificationPriority = result.stopped || result.failedFiles > 0 || result.partialPackages > 0 || result.failedPackages > 0 + ? "error" + : "success"; + const type: NotificationEventType = result.stopped ? "run_stopped" : "run_completed"; + const title = result.stopped + ? "⏹️ Durchlauf gestoppt" + : priority === "success" + ? "🏁 Durchlauf beendet" + : "⚠️ Durchlauf mit Fehlern beendet"; + return event( + `run:${result.id}:${type}`, + type, + priority, + result.completedAt, + title, + result.stopped ? "Offene Dateien bleiben in der Warteschlange." : "Alle Paketresultate sind final.", + priority === "success" ? 0x2ecc71 : 0xe67e22, + [ + { name: "Pakete", value: `${result.completedPackages} fertig · ${result.partialPackages} teilweise · ${result.failedPackages} fehlgeschlagen · ${result.cancelledPackages} abgebrochen`, inline: false }, + { name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false }, + { name: "Dauer", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Nachbearbeitung ${formatDuration(result.postProcessDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false }, + { name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true }, + { name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true }, + { name: "Entpackfehler", value: String(result.extractionFailures), inline: true }, + { name: "Remuxfehler", value: String(result.remuxFailures), inline: true }, + { name: "Downloadfehler", value: String(result.downloadFailures), inline: true }, + { name: "Offline", value: String(result.offlineFailures), inline: true } + ] + ); +} + +export function buildHistoryEntry( + result: PackageResult, + context: HistoryEntryContext +): HistoryEntry { + return { + id: `hist-${result.packageId}-${context.generation}`, + name: result.name, + totalBytes: result.totalBytes, + downloadedBytes: result.downloadedBytes, + fileCount: result.successfulFiles + result.failedFiles + result.cancelledFiles, + provider: context.provider, + completedAt: result.completedAt, + durationSeconds: result.downloadDurationSeconds, + status: result.status, + outputDir: context.outputDir, + urls: [...new Set(context.urls.filter(Boolean))], + startedAt: result.startedAt, + downloadEndedAt: result.downloadEndedAt, + postProcessStartedAt: result.postProcessStartedAt, + downloadDurationSeconds: result.downloadDurationSeconds, + extractionDurationSeconds: result.extractionDurationSeconds, + remuxDurationSeconds: result.remuxDurationSeconds, + postProcessDurationSeconds: result.postProcessDurationSeconds, + totalDurationSeconds: result.totalDurationSeconds, + successfulFiles: result.successfulFiles, + failedFiles: result.failedFiles, + cancelledFiles: result.cancelledFiles, + archiveCount: result.archiveCount, + partCount: result.partCount, + outputCount: result.outputCount, + failurePhase: result.failurePhase, + archiveOperations: result.archiveOperations.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] })), + remuxOperations: result.remuxOperations.map((operation) => ({ ...operation })) + }; +} diff --git a/src/renderer/views/history/HistoryView.tsx b/src/renderer/views/history/HistoryView.tsx index 7e1e077..48c7641 100644 --- a/src/renderer/views/history/HistoryView.tsx +++ b/src/renderer/views/history/HistoryView.tsx @@ -20,6 +20,7 @@ import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar"; import { SlidingSelection } from "../../ui/SlidingSelection"; import { createHistoryTableColumnWidths, + formatHistoryDuration, getHistoryTableGridTemplate, getHistoryTableMinWidth, HISTORY_TABLE_COLUMN_IDS, @@ -69,6 +70,12 @@ const HISTORY_DISCLOSURE_DURATION_MS = 520; const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null; +function operationStatusLabel(status: "completed" | "failed" | "cancelled"): string { + if (status === "completed") return "Abgeschlossen"; + if (status === "failed") return "Fehlgeschlagen"; + return "Abgebrochen"; +} + function loadHistoryTableColumnWidths(): HistoryTableColumnWidths { try { const stored = typeof window === "undefined" ? null : window.localStorage.getItem(HISTORY_TABLE_COLUMN_STORAGE_KEY); @@ -183,11 +190,59 @@ function HistoryRowDetails({
Provider
{row.providerLabel}
Dateien
{row.fileCount}
-
Dauer
{row.durationLabel}
+ {row.hasStructuredLifecycle ? ( + <> +
Download gestartet
{row.startedLabel}
+
Download beendet
{row.downloadEndedLabel}
+
Nachbearbeitung gestartet
{row.postProcessStartedLabel}
+
Abgeschlossen
{row.completedLabel}
+
Downloaddauer
{row.downloadDurationLabel}
+
Entpackdauer
{row.extractionDurationLabel}
+
Remuxdauer
{row.remuxDurationLabel}
+
Nachbearbeitungsdauer
{row.postProcessDurationLabel}
+
Gesamtdauer
{row.totalDurationLabel}
+
Status
{row.statusLabel}
+
Erfolgreich / Fehlgeschlagen / Abgebrochen
{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}
+
Archive / Parts / Ausgaben
{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}
+
Fehlerphase
{row.failurePhaseLabel}
+ + ) : ( +
Downloaddauer (Altbestand)
{row.durationLabel}
+ )}
Durchschnitt
{row.averageSpeedLabel}
Zielordner
{row.outputDir || "—"}
URLs
{row.urls?.length ? row.urls.join("\n") : "—"}
+ {row.hasStructuredLifecycle ? ( +
+
+

Archivvorgänge

+ {row.archiveOperations?.length ? ( +
    + {row.archiveOperations.map((operation) => ( +
  • + {operation.name} + {operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""} +
  • + ))} +
+ ) :

Keine Archivvorgänge

} +
+
+

Remuxvorgänge

+ {row.remuxOperations?.length ? ( +
    + {row.remuxOperations.map((operation) => ( +
  • + {operation.fileName} + {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""} +
  • + ))} +
+ ) :

Keine Remuxvorgänge

} +
+
+ ) : null} diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index 8be1a9a..020a888 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -14,6 +14,15 @@ export interface HistoryRow extends HistoryViewEntry { durationLabel: string; averageSpeedLabel: string; statusLabel: string; + hasStructuredLifecycle: boolean; + downloadEndedLabel: string; + postProcessStartedLabel: string; + downloadDurationLabel: string; + extractionDurationLabel: string; + remuxDurationLabel: string; + postProcessDurationLabel: string; + totalDurationLabel: string; + failurePhaseLabel: string; } export interface HistoryFilterCounts { @@ -147,7 +156,7 @@ function formatBytes(bytes: number): string { return `${numberFormatter.format(value)} ${units[unitIndex]}`; } -function formatDuration(durationSeconds: number): string { +export function formatHistoryDuration(durationSeconds: number): string { const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0)); const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); @@ -158,6 +167,19 @@ function formatDuration(durationSeconds: number): string { return `${minutes}:${String(seconds).padStart(2, "0")}`; } +function formatTimestamp(timestamp: number | undefined): string { + const safe = Math.max(0, Number.isFinite(timestamp) ? Number(timestamp) : 0); + return safe > 0 ? dateFormatter.format(new Date(safe)) : "—"; +} + +function failurePhaseLabel(entry: HistoryViewEntry): string { + if (entry.failurePhase === "download") return "Download"; + if (entry.failurePhase === "extract") return "Entpacken"; + if (entry.failurePhase === "remux") return "Remux"; + if (entry.failurePhase === "cleanup") return "Aufräumen"; + return "—"; +} + export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage { const totalItems = rows.length; const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE)); @@ -234,7 +256,11 @@ export function deriveHistoryHoster(urls: string[] | undefined): string { return hostnames.length > 0 ? hostnames.join(", ") : "—"; } -export function deriveHistoryStartAt(entry: Pick): number { +export function deriveHistoryStartAt(entry: Pick): number { + const startedAt = Math.max(0, Number.isFinite(entry.startedAt) ? Number(entry.startedAt) : 0); + if (startedAt > 0) { + return startedAt; + } const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0); const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000; return Math.max(0, completedAt - durationMs); @@ -244,19 +270,32 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow { const hoster = deriveHistoryHoster(entry.urls); const providerLabel = entry.provider ? providerLabels[entry.provider] : "—"; const startAt = deriveHistoryStartAt(entry); - const durationSeconds = Math.max(0, entry.durationSeconds || 0); - const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0; + const downloadDurationSeconds = Math.max(0, entry.downloadDurationSeconds ?? entry.durationSeconds ?? 0); + const averageBytesPerSecond = downloadDurationSeconds > 0 ? entry.downloadedBytes / downloadDurationSeconds : 0; + const hasStructuredLifecycle = entry.startedAt !== undefined + || entry.downloadEndedAt !== undefined + || entry.postProcessStartedAt !== undefined + || entry.totalDurationSeconds !== undefined; return { ...entry, hoster, providerLabel, startAt, sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`, - startedLabel: dateFormatter.format(new Date(startAt)), - completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))), - durationLabel: formatDuration(durationSeconds), - averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—", - statusLabel: statusLabels[entry.status] + startedLabel: formatTimestamp(startAt), + completedLabel: formatTimestamp(entry.completedAt), + durationLabel: formatHistoryDuration(downloadDurationSeconds), + averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—", + statusLabel: statusLabels[entry.status], + hasStructuredLifecycle, + downloadEndedLabel: formatTimestamp(entry.downloadEndedAt), + postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt), + downloadDurationLabel: formatHistoryDuration(entry.downloadDurationSeconds ?? 0), + extractionDurationLabel: formatHistoryDuration(entry.extractionDurationSeconds ?? 0), + remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0), + postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0), + totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0), + failurePhaseLabel: failurePhaseLabel(entry) }; } diff --git a/src/renderer/views/history/history.css b/src/renderer/views/history/history.css index f06166b..c306e8a 100644 --- a/src/renderer/views/history/history.css +++ b/src/renderer/views/history/history.css @@ -339,11 +339,23 @@ color: var(--ui-danger-text); } -.history-status-failed { - background: color-mix(in srgb, var(--ui-danger) 15%, transparent); - border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border)); +.history-status-failed { + background: color-mix(in srgb, var(--ui-danger) 15%, transparent); + border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border)); color: var(--ui-danger-text); -} +} + +.history-status-partial { + background: color-mix(in srgb, var(--ui-warning) 16%, transparent); + border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border)); + color: var(--ui-warning-text); +} + +.history-status-cancelled { + background: color-mix(in srgb, var(--ui-text-muted) 14%, transparent); + border-color: color-mix(in srgb, var(--ui-text-muted) 48%, var(--ui-border)); + color: var(--ui-text-secondary); +} .history-row-size, .history-row-hoster, @@ -422,9 +434,55 @@ min-width: 0; } -.history-details-grid .history-detail-wide { - grid-column: span 2; -} +.history-details-grid .history-detail-wide { + grid-column: span 2; +} + +.history-operation-groups { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 12px; +} + +.history-operation-group { + background: color-mix(in srgb, var(--ui-panel) 72%, transparent); + border: 1px solid color-mix(in srgb, var(--ui-border) 82%, transparent); + border-radius: 6px; + min-width: 0; + padding: 10px 11px; +} + +.history-operation-group h3 { + color: var(--ui-text-muted); + font-size: 11px; + margin: 0 0 8px; + text-transform: uppercase; +} + +.history-operation-group p, +.history-operation-group ul { + color: var(--ui-text-secondary); + margin: 0; +} + +.history-operation-group ul { + display: grid; + gap: 8px; + list-style: none; + padding: 0; +} + +.history-operation-group li { + display: grid; + gap: 3px; + min-width: 0; +} + +.history-operation-group strong, +.history-operation-group span { + overflow-wrap: anywhere; +} .history-copyable { overflow-wrap: anywhere; @@ -494,9 +552,13 @@ overflow: hidden; } - .history-action { - padding: 0 9px; - } + .history-action { + padding: 0 9px; + } + + .history-operation-groups { + grid-template-columns: 1fr; + } } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 8e98375..408e7f3 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -611,6 +611,14 @@ describe("disk write recovery", () => { deficitBytes: 640 })); expect((manager as any).diskReservations.getReservedBytesByVolume().get("remux-volume") ?? 0).toBe(0); + expect(pkg.remuxOperations).toHaveLength(1); + expect(pkg.remuxOperations?.[0]).toMatchObject({ + fileName: "Show.S01E01.German.DL.720p.mkv", + status: "failed", + errorCategory: "disk_full" + }); + expect(pkg.remuxOperations?.[0].completedAt).toBeGreaterThanOrEqual(pkg.remuxOperations?.[0].startedAt || 0); + expect(pkg.remuxOperations?.[0].durationMs).toBeGreaterThanOrEqual(0); }); }); @@ -11035,10 +11043,19 @@ describe("download manager", () => { manager.getSnapshot().session.packages[packageId]?.status === "completed", 25000 ); - const snapshot = manager.getSnapshot(); - expect(snapshot.session.packages[packageId]?.status).toBe("completed"); - expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); - }, 30000); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); + expect(snapshot.session.packages[packageId]?.archiveOperations).toHaveLength(1); + expect(snapshot.session.packages[packageId]?.archiveOperations?.[0]).toMatchObject({ + name: "episode.zip", + itemIds: [itemId], + partCount: 1, + status: "completed", + errorCategory: "" + }); + expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0); + }, 30000); it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -14516,7 +14533,7 @@ describe("mega-debrid api/web resolution overlap gate", () => { }); }); -describe("package priority ordering", () => { +describe("package priority ordering", () => { function buildPriorityManager(priorities: Array<[string, "high" | "normal" | "low"]>): { manager: DownloadManager; session: ReturnType; @@ -14586,7 +14603,7 @@ describe("package priority ordering", () => { expect(session.packageOrder).toEqual(["high-a", "normal-a", "target", "low-a"]); }); - it("keeps an unchanged priority in place", () => { + it("keeps an unchanged priority in place", () => { const { manager, session } = buildPriorityManager([ ["high-a", "high"], ["high-b", "high"], @@ -14595,6 +14612,72 @@ describe("package priority ordering", () => { manager.setPackagePriority("high-a", "high"); - expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]); - }); -}); + expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]); + }); +}); + +describe("package lifecycle telemetry boundaries", () => { + 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); + const session = emptySession(); + const packageId = "lifecycle-package"; + const itemId = "lifecycle-item"; + const createdAt = Date.now() - 5_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Lifecycle", + outputDir: path.join(root, "downloads", "lifecycle"), + extractDir: path.join(root, "extract", "lifecycle"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + downloadStartedAt: createdAt, + downloadCompletedAt: createdAt + 1_000, + downloadEndedAt: createdAt + 1_000, + createdAt, + updatedAt: createdAt + 1_000 + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/lifecycle", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 65_536, + totalBytes: 65_536, + progressPercent: 100, + fileName: "lifecycle.txt", + targetPath: path.join(root, "downloads", "lifecycle", "lifecycle.txt"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + 1_000 + }; + fs.mkdirSync(path.dirname(session.items[itemId].targetPath), { recursive: true }); + fs.writeFileSync(session.items[itemId].targetPath, Buffer.alloc(65_536, 1)); + const manager = new DownloadManager( + { ...defaultSettings(), autoExtract: false }, + session, + createStoragePaths(path.join(root, "state")) + ); + const state = manager as any; + state.runPackageIds.add(packageId); + state.handlePackagePostProcessing = vi.fn(async () => undefined); + + await state.runPackagePostProcessing(packageId); + + const pkg = session.packages[packageId]; + expect(session.items[itemId].status).toBe("completed"); + expect(pkg.postProcessQueuedAt).toBeGreaterThan(0); + expect(pkg.postProcessStartedAt).toBeGreaterThanOrEqual(pkg.postProcessQueuedAt || 0); + expect(pkg.postProcessCompletedAt).toBeGreaterThanOrEqual(pkg.postProcessStartedAt || 0); + expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt); + }); +}); diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index 6813760..0a3e2dc 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -185,7 +185,7 @@ describe("history model", () => { expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]); }); - it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => { + it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => { expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com"); expect(deriveHistoryHoster([])).toBe("—"); expect(deriveHistoryHoster(undefined)).toBe("—"); @@ -194,8 +194,22 @@ describe("history model", () => { const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0]; expect(row.hoster).toBe("—"); - expect(row.providerLabel).toBe("Real-Debrid"); - }); + expect(row.providerLabel).toBe("Real-Debrid"); + }); + + it("uses the authoritative lifecycle start instead of reconstructing it from completion", () => { + const lifecycleStartedAt = todayStart - 45_000; + const lifecycle = entry({ + id: "lifecycle-start", + name: "Lifecycle", + startedAt: lifecycleStartedAt, + completedAt: todayStart + 60_000, + durationSeconds: 5 + }); + + expect(deriveHistoryStartAt(lifecycle)).toBe(lifecycleStartedAt); + expect(filterHistoryRows([lifecycle], "all", "", now)[0].startAt).toBe(lifecycleStartedAt); + }); it("prunes removed ids and preserves the original set instance when every id survives", () => { const stable = new Set(["today", "week"]); @@ -597,6 +611,94 @@ describe("HistoryView", () => { expect(html).toContain("https://rapidgator.net/file/test"); }); + it("renders authoritative lifecycle timings, counts, failure phase and operation details", () => { + const structured = entry({ + id: "structured", + name: "Strukturiert", + status: "partial", + startedAt: todayStart, + downloadEndedAt: todayStart + 60_000, + postProcessStartedAt: todayStart + 65_000, + completedAt: todayStart + 90_000, + downloadDurationSeconds: 60, + extractionDurationSeconds: 12, + remuxDurationSeconds: 8, + postProcessDurationSeconds: 25, + totalDurationSeconds: 90, + successfulFiles: 3, + failedFiles: 1, + cancelledFiles: 0, + archiveCount: 1, + partCount: 16, + outputCount: 10, + failurePhase: "remux", + archiveOperations: [{ + id: "archive-1", + name: "show.part01.rar", + itemIds: ["item-1"], + partCount: 16, + startedAt: todayStart + 65_000, + completedAt: todayStart + 77_000, + durationMs: 12_000, + status: "completed", + errorCategory: "" + }], + remuxOperations: [{ + id: "remux-1", + fileName: "episode.mkv", + startedAt: todayStart + 77_000, + completedAt: todayStart + 85_000, + durationMs: 8_000, + status: "failed", + errorCategory: "ffmpeg" + }] + }); + const html = renderToStaticMarkup( + + ); + + for (const label of [ + "Download gestartet", + "Download beendet", + "Nachbearbeitung gestartet", + "Abgeschlossen", + "Downloaddauer", + "Entpackdauer", + "Remuxdauer", + "Nachbearbeitungsdauer", + "Gesamtdauer", + "Erfolgreich / Fehlgeschlagen / Abgebrochen", + "Archive / Parts / Ausgaben", + "Fehlerphase", + "Archivvorgänge", + "Remuxvorgänge" + ]) { + expect(html).toContain(label); + } + expect(html).toContain("show.part01.rar"); + expect(html).toContain("16 Parts"); + expect(html).toContain("episode.mkv"); + expect(html).toContain("ffmpeg"); + expect(html).not.toContain("Downloaddauer (Altbestand)"); + }); + + it("labels durationSeconds honestly for legacy entries", () => { + const legacy = entry({ id: "legacy", name: "Altbestand" }); + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("Downloaddauer (Altbestand)"); + expect(html).not.toContain("Download beendet"); + expect(html).not.toContain("Nachbearbeitung gestartet"); + }); + it("uses the global animation setting for the history disclosure surface", () => { const animated = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, true); const immediate = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, false); diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 582422f..71fd7c3 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -1,153 +1,360 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../src/main/notify", async (importActual) => { - const actual = await importActual(); - return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) }; -}); - -import { DownloadManager } from "../src/main/download-manager"; -import { defaultSettings } from "../src/main/constants"; -import { createStoragePaths, emptySession } from "../src/main/storage"; -import { shutdownItemLogs } from "../src/main/item-log"; -import { shutdownPackageLogs } from "../src/main/package-log"; -import { shutdownRenameLog } from "../src/main/rename-log"; -import { sendNotification } from "../src/main/notify"; - -const mockedSend = sendNotification as unknown as ReturnType; -const tempDirs: string[] = []; - -afterEach(() => { - mockedSend.mockClear(); - shutdownItemLogs(); - shutdownPackageLogs(); - shutdownRenameLog(); - for (const dir of tempDirs.splice(0)) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } - } -}); - -function setup(): { manager: DownloadManager; session: ReturnType } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-")); - tempDirs.push(root); - const session = emptySession(); - const manager = new DownloadManager( - { - ...defaultSettings(), - token: "rd-token", - outputDir: path.join(root, "out"), - extractDir: path.join(root, "extract"), - notifyUrl: "https://discord.com/api/webhooks/123/abc", - notifyOnPackageCompleted: true, - notifyOnPackageFailed: true - }, - session, - createStoragePaths(path.join(root, "state")) - ); - return { manager, session }; -} - -function addPackage(session: ReturnType, itemStatuses: string[]): any { - const pkgId = "pkg-1"; - const pkg: any = { - id: pkgId, - name: "Test.Show.S01", - outputDir: "C:/out", - extractDir: "C:/extract", - status: "queued", - itemIds: itemStatuses.map((_s, i) => `it-${i}`), - cancelled: false, - enabled: true, - priority: "normal", - createdAt: 1, - updatedAt: 1 - }; - session.packages[pkgId] = pkg; - session.packageOrder.push(pkgId); - itemStatuses.forEach((status, i) => { - session.items[`it-${i}`] = { - id: `it-${i}`, - packageId: pkgId, - url: `https://dummy/${i}`, - provider: null, - status, - retries: 0, - speedBps: 0, - downloadedBytes: 0, - totalBytes: null, - progressPercent: 0, - fileName: `f${i}.rar`, - targetPath: "", - resumable: true, - attempts: 1, - lastError: "", - fullStatus: "", - createdAt: 1, - updatedAt: 1 - } as any; - }); - return pkg; -} - -describe("refreshPackageStatus failed-transition notify", () => { - it("notifies a MIXED package (some success, last finisher failed) — the lost-webhook case", () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["completed", "failed"]); - session.running = true; - - (manager as any).refreshPackageStatus(pkg); - - expect(pkg.status).toBe("failed"); - expect(mockedSend).toHaveBeenCalledTimes(1); - expect(mockedSend.mock.calls[0][1].title).toBe("❌ Paket fehlgeschlagen"); - expect(mockedSend.mock.calls[0][1].message).toContain("1 von 2"); - }); - - it("notifies an all-failed package and dedups repeat refreshes", () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["failed", "failed"]); - session.running = true; - - (manager as any).refreshPackageStatus(pkg); - (manager as any).refreshPackageStatus(pkg); - - expect(pkg.status).toBe("failed"); - expect(mockedSend).toHaveBeenCalledTimes(1); - }); - - it("stays silent outside a run (startup recovery must not spam)", () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["failed"]); - session.running = false; - - (manager as any).refreshPackageStatus(pkg); - - expect(pkg.status).toBe("failed"); - expect(mockedSend).not.toHaveBeenCalled(); - }); - - it("does not notify while items are still pending", () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["failed", "queued"]); - session.running = true; - - (manager as any).refreshPackageStatus(pkg); - - expect(pkg.status).toBe("queued"); - expect(mockedSend).not.toHaveBeenCalled(); - }); - - it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => { - const { manager, session } = setup(); - const pkg = addPackage(session, ["failed", "failed"]); - session.running = true; - mockedSend.mockResolvedValueOnce(false); - - (manager as any).refreshPackageStatus(pkg); - await new Promise((r) => setTimeout(r, 0)); - - expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false); - }); -}); +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DownloadManager } from "../src/main/download-manager"; +import { defaultSettings } from "../src/main/constants"; +import type { NotificationEvent } from "../src/main/notification-outbox"; +import { createStoragePaths, emptySession } from "../src/main/storage"; +import { shutdownItemLogs } from "../src/main/item-log"; +import { shutdownPackageLogs } from "../src/main/package-log"; +import { shutdownRenameLog } from "../src/main/rename-log"; +import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types"; + +const tempDirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + shutdownItemLogs(); + shutdownPackageLogs(); + shutdownRenameLog(); + for (const dir of tempDirs.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + } + } +}); + +function setup(settings: Partial = {}): { + manager: DownloadManager; + session: ReturnType; + events: NotificationEvent[]; + history: HistoryEntry[]; +} { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-")); + tempDirs.push(root); + const session = emptySession(); + const events: NotificationEvent[] = []; + const history: HistoryEntry[] = []; + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "out"), + extractDir: path.join(root, "extract"), + notifyUrl: "https://discord.com/api/webhooks/123/abc", + notifyOnPackageCompleted: true, + notifyOnPackageFailed: true, + notifyOnRunFinished: true, + notifyPackageSuccessMode: "individual", + autoExtract: false, + ...settings + }, + session, + createStoragePaths(path.join(root, "state")), + { + enqueueNotification: async (event: NotificationEvent) => { + events.push(event); + }, + onHistoryEntry: (entry) => history.push(entry) + } + ); + return { manager, session, events, history }; +} + +function addPackage( + session: ReturnType, + statuses: Array<"completed" | "failed" | "cancelled" | "queued"> = ["completed"], + packageId = "pkg-1" +): PackageEntry { + const startedAt = Date.now() - 30_000; + const pkg: PackageEntry = { + id: packageId, + name: `Test ${packageId}`, + outputDir: `C:/out/${packageId}`, + extractDir: `C:/extract/${packageId}`, + status: "queued", + itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`), + cancelled: false, + enabled: true, + priority: "normal", + downloadStartedAt: startedAt, + downloadCompletedAt: startedAt + 10_000, + downloadEndedAt: startedAt + 10_000, + createdAt: startedAt, + updatedAt: startedAt + 10_000 + }; + session.packages[packageId] = pkg; + session.packageOrder.push(packageId); + statuses.forEach((status, index) => { + const itemId = `${packageId}-item-${index}`; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://dummy/${packageId}/${index}`, + provider: "realdebrid", + status, + retries: 0, + speedBps: 0, + downloadedBytes: status === "completed" ? 1_000 : 0, + totalBytes: 1_000, + progressPercent: status === "completed" ? 100 : 0, + fileName: `${packageId}-${index}.rar`, + targetPath: `C:/out/${packageId}/${packageId}-${index}.rar`, + resumable: true, + attempts: 1, + lastError: status === "failed" ? "offline" : "", + fullStatus: status === "completed" ? "Fertig" : status === "failed" ? "Offline" : "Wartet", + createdAt: startedAt, + updatedAt: startedAt + 10_000 + }; + }); + return pkg; +} + +function internal(manager: DownloadManager): any { + return manager as any; +} + +async function flushNotifications(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("authoritative package completion", () => { + it("waits for main, deferred, hybrid and file operations before emitting one package result", async () => { + const { manager, session, events, history } = setup(); + const pkg = addPackage(session); + const state = internal(manager); + state.runPackageIds.add(pkg.id); + state.packagePostProcessTasks.set(pkg.id, Promise.resolve()); + state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()])); + state.packageHybridPostProcessTasks.set(pkg.id, new Set([Promise.resolve()])); + state.packageFileOpChain.set(pkg.id, Promise.resolve()); + + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + expect(events).toHaveLength(0); + expect(history).toHaveLength(0); + + state.packagePostProcessTasks.delete(pkg.id); + state.packageDeferredPostProcessTasks.delete(pkg.id); + state.packageHybridPostProcessTasks.delete(pkg.id); + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + expect(events).toHaveLength(0); + + state.packageFileOpChain.delete(pkg.id); + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["package_completed"]); + expect(history).toHaveLength(1); + expect(pkg.postProcessCompletedAt).toBeGreaterThan(0); + expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt); + }); + + it("turns a deferred remux failure into one immediate failed package event", async () => { + const { manager, session, events, history } = setup({ notifyPackageSuccessMode: "digest" }); + const pkg = addPackage(session); + const state = internal(manager); + state.runPackageIds.add(pkg.id); + pkg.remuxOperations = [{ + id: "remux-1", + fileName: "episode.mkv", + startedAt: 10_000, + completedAt: 14_000, + durationMs: 4_000, + status: "failed", + errorCategory: "ffmpeg" + }]; + state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()])); + + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + expect(events).toHaveLength(0); + + state.packageDeferredPostProcessTasks.delete(pkg.id); + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["package_failed"]); + expect(events[0].priority).toBe("error"); + expect(history[0]).toMatchObject({ status: "failed", failurePhase: "remux", failedFiles: 1 }); + }); + + it("emits a partial package result when the terminal downloads are mixed", async () => { + const { manager, session, events, history } = setup(); + const pkg = addPackage(session, ["completed", "failed"]); + session.running = true; + internal(manager).runPackageIds.add(pkg.id); + + internal(manager).refreshPackageStatus(pkg); + await flushNotifications(); + + expect(pkg.status).toBe("failed"); + expect(events.map((event) => event.type)).toEqual(["package_partial"]); + expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 }); + }); + + it("creates a new result generation when extraction is retried", async () => { + const { manager, session, events, history } = setup(); + const pkg = addPackage(session); + const state = internal(manager); + state.runPackageIds.add(pkg.id); + pkg.archiveOperations = [{ + id: "archive-1", + name: "episode.rar", + itemIds: [...pkg.itemIds], + partCount: 1, + startedAt: 10_000, + completedAt: 12_000, + durationMs: 2_000, + status: "failed", + errorCategory: "crc_error" + }]; + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + const firstId = events[0]?.id; + + const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined); + session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error"; + manager.retryExtraction(pkg.id); + expect(postProcess).toHaveBeenCalledWith(pkg.id); + pkg.archiveOperations = [{ + id: "archive-2", + 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(2); + expect(events[1].id).not.toBe(firstId); + expect(events[1].type).toBe("package_completed"); + expect(history).toHaveLength(2); + }); + + it("moves a pending success digest into the outbox before shutdown", async () => { + const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" }); + const pkg = addPackage(session); + const state = internal(manager); + state.runPackageIds.add(pkg.id); + + state.tryFinalizePackageResult(pkg.id); + await flushNotifications(); + expect(events).toHaveLength(0); + + await state.flushNotificationsForShutdown?.(); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["package_completed"]); + expect(events[0].payload.title).toContain("Paket-Digest"); + }); +}); + +describe("authoritative run completion", () => { + it("emits run_stopped without run_completed for a manual stop", async () => { + const { manager, session, events } = setup(); + const pkg = addPackage(session, ["completed", "queued"]); + const state = internal(manager); + 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], "completed"]]); + + manager.stop(); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["run_stopped"]); + expect(events.some((event) => event.type === "run_completed")).toBe(false); + }); + + 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); + const state = internal(manager); + session.running = true; + session.runStartedAt = Date.now() - 20_000; + state.runItemIds = new Set(pkg.itemIds); + state.runPackageIds = new Set([pkg.id]); + state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]); + state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()])); + pkg.archiveOperations = [{ + id: "archive-failed", + name: "episode.part01.rar", + itemIds: [...pkg.itemIds], + partCount: 16, + startedAt: 10_000, + completedAt: 18_000, + durationMs: 8_000, + status: "failed", + errorCategory: "wrong_password" + }]; + + state.finishRun(); + await flushNotifications(); + expect(events).toHaveLength(0); + + state.packageDeferredPostProcessTasks.delete(pkg.id); + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["package_failed", "run_completed"]); + const runEvent = events[1]; + expect(runEvent.payload.fields.some((field) => field.name === "Entpackfehler" && field.value === "1")).toBe(true); + expect(runEvent.payload.fields.some((field) => field.name === "Dateien" && field.value === "0 erfolgreich · 1 fehlgeschlagen · 0 abgebrochen")).toBe(true); + }); + + it("flushes successful package digests before run_completed", async () => { + const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" }); + const pkg = addPackage(session); + const state = internal(manager); + session.running = true; + session.runStartedAt = Date.now() - 20_000; + state.runItemIds = new Set(pkg.itemIds); + state.runPackageIds = new Set([pkg.id]); + state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]); + + state.finishRun(); + state.tryFinalizePackageResult?.(pkg.id); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]); + expect(events[0].payload.title).toContain("Paket-Digest"); + }); + + it("keeps a finalized success in the digest after package_done removes its session package", async () => { + const { manager, session, events } = setup({ + notifyPackageSuccessMode: "digest", + completedCleanupPolicy: "package_done" + }); + const pkg = addPackage(session); + const state = internal(manager); + session.running = true; + 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); + state.applyPackageDoneCleanup(pkg.id); + expect(session.packages[pkg.id]).toBeUndefined(); + state.finishRun(); + await flushNotifications(); + + expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]); + expect(events[0].payload.title).toContain("Paket-Digest"); + }); +});