From e8046f3fa3f32913e1f5f4c40de821dc3006bb00 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 24 Aug 2026 20:59:44 +0200 Subject: [PATCH] feat(history): complete package lifecycle telemetry Persist separate download, extraction, remux, post-processing, and total durations together with accurate file, byte, archive, part, output, and phase failure results. Keep download item counts independent from extraction and remux outcomes, distinguish partial and cancelled packages, and expose additive failure counters and fixed categories in history and Discord notifications. Track archive operations across parallel work, aborts, restarts, nested multipart sets, and skipped split files while deriving generation outputs from privacy-safe signatures and merging overlapping extraction intervals. --- src/main/download-manager.ts | 328 ++++++++++++---- src/main/extractor.ts | 43 +- src/main/notification-events.ts | 18 +- src/main/notification-outbox.ts | 10 +- src/main/package-telemetry.ts | 66 +++- src/main/storage.ts | 18 +- src/renderer/views/history/HistoryView.tsx | 8 +- src/renderer/views/history/history-model.ts | 27 +- src/shared/types.ts | 13 +- tests/download-manager.test.ts | 410 +++++++++++++++++++- tests/extractor.test.ts | 76 +++- tests/history-view.test.tsx | 43 +- tests/notification-outbox.test.ts | 72 ++++ tests/notify-hooks.test.ts | 78 +++- tests/package-telemetry.test.ts | 55 ++- tests/storage.test.ts | 24 +- 16 files changed, 1139 insertions(+), 150 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index fdd1232..822ddcc 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1977,6 +1977,8 @@ export class DownloadManager extends EventEmitter { private finalizedPackageResults = new Map(); + private activeArchiveOperations = new Map(); + private runContexts = new Map(); private activeRunContextId: string | null = null; @@ -4208,12 +4210,12 @@ export class DownloadManager extends EventEmitter { return false; } - private async countPackageOutputFiles(rootDir: string): Promise { + private async collectPackageOutputSignatures(rootDir: string): Promise> { if (!rootDir) { - return 0; + return new Set(); } const stack = [rootDir]; - let count = 0; + const signatures = new Set(); while (stack.length > 0) { const current = stack.pop() as string; let entries: fs.Dirent[] = []; @@ -4230,11 +4232,31 @@ export class DownloadManager extends EventEmitter { if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile() && !isArchiveLikePath(fullPath) && !isIgnorableEmptyDirFileName(entry.name)) { - count += 1; + try { + const stat = await fs.promises.stat(fullPath); + const relativeIdentity = path.relative(rootDir, fullPath).replace(/\\/g, "/").normalize("NFC").toLocaleLowerCase("de-DE"); + signatures.add(createHash("sha256").update(`${relativeIdentity}\0${stat.size}\0${Math.floor(stat.mtimeMs)}`).digest("hex")); + } catch { + } } } } - return count; + return signatures; + } + + private async capturePackageOutputBaseline(pkg: PackageEntry): Promise { + if (pkg.outputBaselineSignatures !== undefined) { + return; + } + pkg.outputBaselineSignatures = [...await this.collectPackageOutputSignatures(pkg.extractDir)].sort(); + } + + private async refreshPackageOutputCount(pkg: PackageEntry): Promise { + await this.capturePackageOutputBaseline(pkg); + const baseline = new Set(pkg.outputBaselineSignatures || []); + const current = await this.collectPackageOutputSignatures(pkg.extractDir); + const generationCount = [...current].filter((signature) => !baseline.has(signature)).length; + pkg.outputCount = Math.max(pkg.outputCount || 0, generationCount); } private async removeEmptyDirectoryTree(rootDir: string): Promise { @@ -5834,9 +5856,10 @@ export class DownloadManager extends EventEmitter { sourceSize }, resolved.item, resolved.matchedBy); await this.moveCompanionFiles(sourcePath, targetPath, pkg); - } catch (error) { - failed += 1; - logger.warn(`MKV verschieben fehlgeschlagen: ${sourcePath} -> ${targetPath} (${compactErrorText(error)})`); + } catch (error) { + failed += 1; + this.recordPostProcessFailure(pkg, "postprocess", error); + logger.warn(`MKV verschieben fehlgeschlagen: ${sourcePath} -> ${targetPath} (${compactErrorText(error)})`); this.logPackageForPackage(pkg, "WARN", "MKV verschieben fehlgeschlagen", { sourcePath, targetPath, @@ -8242,6 +8265,7 @@ export class DownloadManager extends EventEmitter { await this.acquirePostProcessSlot(packageId); const startedPackage = this.session.packages[packageId]; if (startedPackage) { + await this.capturePackageOutputBaseline(startedPackage); startedPackage.postProcessStartedAt = startedPackage.postProcessStartedAt || nowMs(); startedPackage.updatedAt = nowMs(); } @@ -8262,11 +8286,16 @@ export class DownloadManager extends EventEmitter { const hadRequeue = this.hybridExtractRequeue.has(packageId); this.hybridExtractRequeue.delete(packageId); const roundStart = nowMs(); - try { - await this.handlePackagePostProcessing(packageId, abortController.signal); - } catch (error) { - logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); - } + try { + await this.handlePackagePostProcessing(packageId, abortController.signal); + } catch (error) { + const failedPackage = this.session.packages[packageId]; + const reason = compactErrorText(error); + if (failedPackage && !reason.includes("aborted:") && reason !== "reset" && reason !== "cancel" && reason !== "package_toggle") { + this.recordPostProcessFailure(failedPackage, "postprocess", error); + } + logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); + } const roundMs = nowMs() - roundStart; logger.info(`Post-Process Runde ${round} fertig in ${(roundMs / 1000).toFixed(1)}s (requeue=${hadRequeue}, nextRequeue=${this.hybridExtractRequeue.has(packageId)}): pkg=${packageId.slice(0, 8)}`); const pkg = this.session.packages[packageId]; @@ -8286,8 +8315,12 @@ export class DownloadManager extends EventEmitter { } } } while (this.hybridExtractRequeue.has(packageId)); - } finally { - this.releasePostProcessSlot(); + } finally { + const finalizingPackage = this.session.packages[packageId]; + if (finalizingPackage) { + this.finalizeActiveArchiveOperations(finalizingPackage); + } + this.releasePostProcessSlot(); // Identity guard: only clear the map entries if they still point to THIS // task/controller. After an abort deletes our handle a new run can install // a fresh task+controller for the same packageId; a blind delete here would @@ -8680,6 +8713,11 @@ export class DownloadManager extends EventEmitter { this.itemCount = Math.max(0, this.itemCount - 1); } delete this.session.packages[packageId]; + for (const [id, active] of this.activeArchiveOperations) { + if (active.packageId === packageId) { + this.activeArchiveOperations.delete(id); + } + } this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); this.runCompletedPackages.delete(packageId); this.resetSessionTotalsIfQueueEmpty(); @@ -12011,7 +12049,14 @@ export class DownloadManager extends EventEmitter { pkg.archiveOperations = []; pkg.remuxOperations = []; pkg.outputCount = 0; + pkg.outputBaselineSignatures = undefined; pkg.cleanupErrorCategory = ""; + pkg.postProcessErrorCategory = ""; + for (const [id, active] of this.activeArchiveOperations) { + if (active.packageId === packageId) { + this.activeArchiveOperations.delete(id); + } + } } this.pruneFinalizedPackageResults(); return next; @@ -12346,7 +12391,8 @@ export class DownloadManager extends EventEmitter { archiveOperations: pkg.archiveOperations, remuxOperations: pkg.remuxOperations, outputCount: pkg.outputCount, - cleanupErrorCategory: pkg.cleanupErrorCategory + cleanupErrorCategory: pkg.cleanupErrorCategory, + postProcessErrorCategory: pkg.postProcessErrorCategory }); this.finalizedPackageResults.set(key, result); pkg.status = result.status === "partial" ? "failed" : result.status; @@ -12763,7 +12809,7 @@ export class DownloadManager extends EventEmitter { return undefined; } - private looksLikeArchivePart(fileName: string, entryPointName: string): boolean { + private looksLikeArchivePart(fileName: string, entryPointName: string): boolean { const multipartMatch = entryPointName.match(/^(.*)\.part0*1\.rar$/i); if (multipartMatch) { const prefix = multipartMatch[1].toLowerCase(); @@ -12789,38 +12835,88 @@ export class DownloadManager extends EventEmitter { const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`^${escaped}\\.\\d{3}$`, "i").test(fileName); } - return false; - } + return false; + } + + private async resolveArchivePartCount(archivePath: string): Promise { + const directory = path.dirname(archivePath); + const directoryFiles = await fs.promises.readdir(directory).catch(() => [] as string[]); + const entryPointName = path.basename(archivePath); + return Math.max(1, directoryFiles.filter((fileName) => this.looksLikeArchivePart(fileName, entryPointName)).length); + } private recordArchiveOperation( pkg: PackageEntry, progress: ExtractProgressUpdate, items: DownloadItem[], - errorCategory = "" + errorCategory = "", + validatedPartCount?: number ): void { - if (!progress.archiveName || progress.archiveDone !== true) { + if (!progress.archiveName) { return; } - const completedAt = nowMs(); + const observedAt = nowMs(); const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0)); const itemIds = [...new Set(items.map((item) => item.id))]; const itemProvenance = items .map((item) => String(item.targetPath || item.id).replace(/\\/g, "/").toLocaleLowerCase("de-DE")) .sort(); - const archiveIdentity = itemProvenance.length > 0 - ? itemProvenance.join("|") - : `${progress.archiveName.toLocaleLowerCase("de-DE")}:${Math.max(0, Math.floor(progress.current))}`; + const archivePath = String((progress as ExtractProgressUpdate & { archivePath?: string }).archivePath || "").trim(); + const archiveIdentity = archivePath + ? path.resolve(archivePath).replace(/\\/g, "/").normalize("NFC").toLocaleLowerCase("de-DE") + : itemProvenance.length > 0 + ? itemProvenance.join("|") + : progress.archiveName.normalize("NFC").toLocaleLowerCase("de-DE"); + const id = `${pkg.id}.${createHash("sha256").update(archiveIdentity).digest("hex").slice(0, 24)}`; + if (progress.archiveDone === true && progress.archiveSkipped === true) { + this.activeArchiveOperations.delete(id); + pkg.archiveOperations = (pkg.archiveOperations || []).filter((operation) => operation.id !== id); + this.persistSoon(); + return; + } + const liveActive = this.activeArchiveOperations.get(id)?.operation; + const active = liveActive || pkg.archiveOperations?.find((operation) => operation.id === id); + const partCount = Math.max(0, Math.floor(Number(validatedPartCount ?? itemIds.length) || 0)); + if (progress.archiveDone !== true) { + if (!liveActive) { + const operation: ArchiveOperationMetric = { + id, + name: progress.archiveName, + itemIds, + partCount, + startedAt: Math.max(0, observedAt - durationMs), + completedAt: observedAt, + durationMs, + status: "cancelled", + errorCategory: "" + }; + this.activeArchiveOperations.set(id, { + packageId: pkg.id, + operation + }); + this.upsertArchiveOperation(pkg, operation); + this.persistSoon(); + } + return; + } + const completedAt = observedAt; + const startedAt = active?.startedAt || Math.max(0, completedAt - durationMs); const operation: ArchiveOperationMetric = { - id: `${pkg.id}:${createHash("sha256").update(archiveIdentity).digest("hex").slice(0, 24)}`, + id, name: progress.archiveName, itemIds, - partCount: itemIds.length, - startedAt: Math.max(0, completedAt - durationMs), + partCount, + startedAt, completedAt, - durationMs, + durationMs: durationMs > 0 ? durationMs : Math.max(0, completedAt - startedAt), status: progress.archiveSuccess === false ? "failed" : "completed", errorCategory: progress.archiveSuccess === false ? projectPackageFailureCategory("extract", errorCategory) : "" }; + this.activeArchiveOperations.delete(id); + this.upsertArchiveOperation(pkg, operation); + } + + private upsertArchiveOperation(pkg: PackageEntry, operation: ArchiveOperationMetric): void { const operations = [...(pkg.archiveOperations || [])]; const existingIndex = operations.findIndex((entry) => entry.id === operation.id); if (existingIndex >= 0) { @@ -12831,6 +12927,23 @@ export class DownloadManager extends EventEmitter { pkg.archiveOperations = operations; } + private finalizeActiveArchiveOperations(pkg: PackageEntry): void { + const completedAt = nowMs(); + for (const [id, active] of this.activeArchiveOperations) { + if (active.packageId !== pkg.id) { + continue; + } + this.activeArchiveOperations.delete(id); + this.upsertArchiveOperation(pkg, { + ...active.operation, + completedAt, + durationMs: Math.max(active.operation.durationMs, completedAt - active.operation.startedAt), + status: "cancelled", + errorCategory: "" + }); + } + } + 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); @@ -12893,10 +13006,14 @@ export class DownloadManager extends EventEmitter { dirFiles = (await fs.promises.readdir(pkg.outputDir, { withFileTypes: true })) .filter((entry) => entry.isFile()) .map((entry) => entry.name); - } catch { } - const archiveStems = new Set(); - for (const archiveKey of readyArchives) { - const parts = collectArchiveCleanupTargets(archiveKey, dirFiles); + } catch { } + const archiveStems = new Set(); + const validatedArchivePartCounts = new Map(); + for (const archiveKey of readyArchives) { + const parts = collectArchiveCleanupTargets(archiveKey, dirFiles); + const validatedPartCount = await this.resolveArchivePartCount(archiveKey); + validatedArchivePartCounts.set(pathKey(archiveKey), validatedPartCount); + validatedArchivePartCounts.set(path.basename(archiveKey).toLowerCase(), validatedPartCount); for (const part of parts) { const partName = path.basename(part).toLowerCase(); hybridFileNames.add(partName); @@ -13074,9 +13191,18 @@ export class DownloadManager extends EventEmitter { hybridLastEmitAt = initAt; this.emitState(true); } - } - const archItems = hybridResolvedItems.get(progress.archiveName) || []; - + } + const archItems = hybridResolvedItems.get(progress.archiveName) || []; + this.recordArchiveOperation( + pkg, + progress, + archItems, + failedArchiveCategories.get(progress.archiveName.toLowerCase()) || "", + validatedArchivePartCounts.get(pathKey(progress.archivePath || "")) + || validatedArchivePartCounts.get(progress.archiveName.toLowerCase()) + || Math.max(1, archItems.length) + ); + if (archiveFinished) { const doneAt = nowMs(); const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; @@ -13087,12 +13213,6 @@ export class DownloadManager extends EventEmitter { 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; @@ -13162,7 +13282,7 @@ export class DownloadManager extends EventEmitter { } } }); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.refreshPackageOutputCount(pkg); logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", { @@ -13525,10 +13645,14 @@ export class DownloadManager extends EventEmitter { throw new Error(String(extractAbortController.signal.reason || "aborted:extract")); } - const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); - const fullExtractItemIds = new Set(); + const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); + const fullArchivePartCounts = new Map(); + const fullExtractItemIds = new Set(); for (const archivePath of fullArchiveSet) { const archiveItems = resolveArchiveItems(path.basename(archivePath)); + const validatedPartCount = await this.resolveArchivePartCount(archivePath); + fullArchivePartCounts.set(pathKey(archivePath), validatedPartCount); + fullArchivePartCounts.set(path.basename(archivePath).toLowerCase(), validatedPartCount); for (const entry of archiveItems) { fullExtractItemIds.add(entry.id); } @@ -13646,21 +13770,24 @@ export class DownloadManager extends EventEmitter { } emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); } - } - const archiveItems = fullResolvedItems.get(progress.archiveName) || []; - + } + const archiveItems = fullResolvedItems.get(progress.archiveName) || []; + this.recordArchiveOperation( + pkg, + progress, + archiveItems, + fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || "", + fullArchivePartCounts.get(pathKey(progress.archivePath || "")) + || fullArchivePartCounts.get(progress.archiveName.toLowerCase()) + || Math.max(1, archiveItems.length) + ); + if (archiveFinished) { const doneAt = nowMs(); const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; 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; @@ -13719,7 +13846,7 @@ export class DownloadManager extends EventEmitter { emitExtractStatus(overallLabel); } }); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.refreshPackageOutputCount(pkg); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", { extracted: result.extracted, @@ -13872,6 +13999,7 @@ export class DownloadManager extends EventEmitter { this.trackPackagePostProcessResult(packageId); const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount) .finally(() => { + this.finalizeActiveArchiveOperations(pkg); const tasks = this.packageDeferredPostProcessTasks.get(packageId); tasks?.delete(task); if (tasks?.size === 0) { @@ -13886,6 +14014,15 @@ export class DownloadManager extends EventEmitter { return task; } + private recordPostProcessFailure(pkg: PackageEntry, phase: "cleanup" | "postprocess", error: unknown): void { + const category = projectPackageFailureCategory(phase, compactErrorText(error)); + if (phase === "cleanup") { + pkg.cleanupErrorCategory = category; + } else { + pkg.postProcessErrorCategory = category; + } + } + private async executeDeferredPostExtraction( packageId: string, pkg: PackageEntry, @@ -13903,10 +14040,33 @@ export class DownloadManager extends EventEmitter { const deferredVersion = this.getPackagePostProcessVersion(packageId); const shouldAbort = (): boolean => !this.isDeferredPostProcessStillCurrent(packageId, pkg, deferredVersion, deferredController.signal); const throwIfAborted = (): void => this.throwIfDeferredPostProcessAborted(packageId, pkg, deferredVersion, deferredController.signal); - const hasBlockingExtractError = pkg.itemIds.some((itemId) => { - const item = this.session.items[itemId]; - return Boolean(item && item.status === "completed" && isExtractErrorLabel(item.fullStatus || "")); - }); + const hasBlockingExtractError = pkg.itemIds.some((itemId) => { + const item = this.session.items[itemId]; + return Boolean(item && item.status === "completed" && isExtractErrorLabel(item.fullStatus || "")); + }); + const isExpectedAbort = (error: unknown): boolean => { + const reason = compactErrorText(error); + return reason.includes("aborted:deferred") + || reason.includes("deferred_replaced") + || reason.includes("package_removed") + || reason === "reset" + || reason === "cancel" + || reason === "overwrite" + || reason === "skip" + || reason === "package_toggle"; + }; + let cleanupFailureRecorded = false; + const runCleanup = async (operation: () => Promise): Promise => { + try { + return await operation(); + } catch (error) { + if (!isExpectedAbort(error)) { + cleanupFailureRecorded = true; + this.recordPostProcessFailure(pkg, "cleanup", error); + } + throw error; + } + }; try { throwIfAborted(); @@ -13921,9 +14081,15 @@ export class DownloadManager extends EventEmitter { this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction gestartet", { nestedCandidates: nestedCandidates.length, extractDir: pkg.extractDir - }); + }); const nestedFailureCategories = new Map(); const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; + const nestedArchivePartCounts = new Map(); + for (const candidate of nestedCandidates) { + const validatedPartCount = await this.resolveArchivePartCount(candidate); + nestedArchivePartCounts.set(pathKey(candidate), validatedPartCount); + nestedArchivePartCounts.set(path.basename(candidate).toLowerCase(), validatedPartCount); + } const nestedResult = await extractPackageArchives({ packageDir: pkg.extractDir, targetDir: pkg.extractDir, @@ -13946,12 +14112,15 @@ export class DownloadManager extends EventEmitter { pkg, progress, resolveArchiveItemsFromList(progress.archiveName, nestedItems), - nestedFailureCategories.get(progress.archiveName.toLowerCase()) || "" + nestedFailureCategories.get(progress.archiveName.toLowerCase()) || "", + nestedArchivePartCounts.get(pathKey(progress.archivePath || "")) + || nestedArchivePartCounts.get(progress.archiveName.toLowerCase()) + || 1 ); } }); throwIfAborted(); - pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir)); + await this.refreshPackageOutputCount(pkg); extractedCount += nestedResult.extracted; logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", { @@ -13986,9 +14155,9 @@ export class DownloadManager extends EventEmitter { } else { const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase(); if (!sourceAndTargetEqual) { - const candidates = await findArchiveCandidates(pkg.outputDir); - if (candidates.length > 0) { - const removed = await cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort }); + const candidates = await runCleanup(() => findArchiveCandidates(pkg.outputDir)); + if (candidates.length > 0) { + const removed = await runCleanup(() => cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort })); if (removed > 0) { logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`); } @@ -13999,7 +14168,7 @@ export class DownloadManager extends EventEmitter { if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none" && !hasBlockingExtractError) { throwIfAborted(); - const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir, shouldAbort); + const removedArchives = await runCleanup(() => this.cleanupRemainingArchiveArtifacts(pkg.outputDir, shouldAbort)); if (removedArchives > 0) { logger.info(`Hybrid-Post-Cleanup entfernte Archive: pkg=${pkg.name}, entfernt=${removedArchives}`); } @@ -14008,13 +14177,13 @@ export class DownloadManager extends EventEmitter { if (extractedCount > 0 || alreadyMarkedExtracted) { throwIfAborted(); if (this.settings.removeLinkFilesAfterExtract) { - const removedLinks = await removeDownloadLinkArtifacts(pkg.extractDir, { shouldAbort }); + const removedLinks = await runCleanup(() => removeDownloadLinkArtifacts(pkg.extractDir, { shouldAbort })); if (removedLinks > 0) { logger.info(`Deferred Link-Cleanup: pkg=${pkg.name}, entfernt=${removedLinks}`); } } if (this.settings.removeSamplesAfterExtract) { - const removedSamples = await removeSampleArtifacts(pkg.extractDir, { shouldAbort }); + const removedSamples = await runCleanup(() => removeSampleArtifacts(pkg.extractDir, { shouldAbort })); if (removedSamples.files > 0 || removedSamples.dirs > 0) { logger.info(`Deferred Sample-Cleanup: pkg=${pkg.name}, files=${removedSamples.files}, dirs=${removedSamples.dirs}`); } @@ -14023,14 +14192,14 @@ export class DownloadManager extends EventEmitter { if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) { throwIfAborted(); - await clearExtractResumeState(pkg.outputDir, packageId); - await clearExtractResumeState(pkg.outputDir); + await runCleanup(() => clearExtractResumeState(pkg.outputDir, packageId)); + await runCleanup(() => clearExtractResumeState(pkg.outputDir)); } if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode === "delete") { throwIfAborted(); - if (!(await hasAnyFilesRecursive(pkg.outputDir))) { - const removedDirs = await removeEmptyDirectoryTree(pkg.outputDir); + if (!(await runCleanup(() => hasAnyFilesRecursive(pkg.outputDir)))) { + const removedDirs = await runCleanup(() => removeEmptyDirectoryTree(pkg.outputDir)); if (removedDirs > 0) { logger.info(`Deferred leere Download-Ordner entfernt: pkg=${pkg.name}, dirs=${removedDirs}`); } @@ -14051,18 +14220,13 @@ export class DownloadManager extends EventEmitter { this.emitState(); } catch (error) { - const reason = compactErrorText(error); - if (reason.includes("aborted:deferred") - || reason.includes("deferred_replaced") - || reason.includes("package_removed") - || reason === "reset" - || reason === "cancel" - || reason === "overwrite" - || reason === "skip" - || reason === "package_toggle") { + const reason = compactErrorText(error); + if (isExpectedAbort(error)) { logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`); } else { - pkg.cleanupErrorCategory = projectPackageFailureCategory("cleanup", reason); + if (!cleanupFailureRecorded) { + this.recordPostProcessFailure(pkg, "postprocess", error); + } logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`); } } finally { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index da640b1..2d45b16 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -63,19 +63,21 @@ export interface ExtractOptions { onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void; } -export interface ExtractProgressUpdate { +export interface ExtractProgressUpdate { current: number; total: number; percent: number; - archiveName: string; + archiveName: string; + archivePath?: string; archivePercent?: number; elapsedMs?: number; phase: "extracting" | "done" | "preparing"; passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean; - archiveDone?: boolean; - archiveSuccess?: boolean; + archiveDone?: boolean; + archiveSuccess?: boolean; + archiveSkipped?: boolean; } export interface ExtractArchiveFailureInfo { @@ -2961,7 +2963,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ archivePercent?: number, elapsedMs?: number, pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean }, - archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean } + archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean; archiveSkipped?: boolean }, + archivePath?: string ): void => { if (!options.onProgress) { return; @@ -2981,7 +2984,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ current, total, percent, - archiveName, + archiveName, + ...(archivePath ? { archivePath: path.resolve(archivePath) } : {}), archivePercent: normalizedArchivePercent, elapsedMs, phase, @@ -2997,7 +3001,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ for (const archivePath of candidates) { if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { - emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }); + emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }, archivePath); } } @@ -3022,9 +3026,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ let archivePercent = 0; let reached99At: number | null = null; let archiveOutcome: "success" | "failed" | "skipped" = "failed"; - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0, undefined, undefined, archivePath); const pulseTimer = setInterval(() => { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, undefined, archivePath); }, 1100); const hybrid = Boolean(options.hybridMode); const filenamePasswords = archiveFilenamePasswords(archiveName); @@ -3041,7 +3045,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ reached99At = Date.now(); logger.info(`Extract-Trace 99%: archive=${archiveName}, elapsedMs=${reached99At - archiveStartedAt}`); } - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, undefined, archivePath); }; const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName); @@ -3057,8 +3061,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ archiveOutcome = "skipped"; const skippedAt = Date.now(); lastArchiveFinishedAt = skippedAt; - logger.info(`Extract-Trace Archiv Übersprungen: archive=${archiveName}, ms=${skippedAt - archiveStartedAt}, reason=no-signature`); - return; + logger.info(`Extract-Trace Archiv Übersprungen: archive=${archiveName}, ms=${skippedAt - archiveStartedAt}, reason=no-signature`); + emitProgress(extracted + failed, archiveName, "extracting", 100, skippedAt - archiveStartedAt, undefined, { archiveDone: true, archiveSkipped: true }, archivePath); + return; } logger.info(`Generische Split-Datei verifiziert (Signatur: ${sig}): ${archiveName}`); } @@ -3069,11 +3074,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`); const hasManyPasswords = archivePasswordCandidates.length > 1; if (hasManyPasswords) { - emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }); + emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length }, undefined, archivePath); } const onPwAttempt = hasManyPasswords ? (attempt: number, total: number) => { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }, undefined, archivePath); options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=`); } : undefined; @@ -3135,9 +3140,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ lastArchiveFinishedAt = successAt; archivePercent = 100; if (hasManyPasswords) { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }, { archiveDone: true, archiveSuccess: true }); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }, { archiveDone: true, archiveSuccess: true }, archivePath); } else { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: true }); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: true }, archivePath); } } catch (error) { const errorText = String(error); @@ -3168,7 +3173,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const tailAfter99Ms = reached99At ? (failedAt - reached99At) : -1; logger.warn(`Extract-Trace Archiv Fehler: archive=${archiveName}, totalMs=${failedAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, category=${errorCategory}`); lastArchiveFinishedAt = failedAt; - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: false }); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: false }, archivePath); if (isNoExtractorError(errorText)) { noExtractorEncountered = true; } @@ -3327,9 +3332,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } const nestedStartedAt = Date.now(); let nestedPercent = 0; - emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, 0); + emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, 0, undefined, undefined, nestedArchive); const nestedPulse = setInterval(() => { - emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, Date.now() - nestedStartedAt); + emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, Date.now() - nestedStartedAt, undefined, undefined, nestedArchive); }, 1100); const hybrid = Boolean(options.hybridMode); logger.info(`Nested-Entpacke: ${nestedName} -> ${options.targetDir}${hybrid ? " (hybrid)" : ""}`); diff --git a/src/main/notification-events.ts b/src/main/notification-events.ts index 58b33a2..6bc1b31 100644 --- a/src/main/notification-events.ts +++ b/src/main/notification-events.ts @@ -43,6 +43,7 @@ export interface RunResult { downloadFailures: number; offlineFailures: number; cleanupFailures: number; + postProcessFailures: number; } export interface RunResultInput { @@ -149,6 +150,7 @@ function failurePhaseLabel(result: PackageResult): string { if (result.failurePhase === "extract") return "Entpacken"; if (result.failurePhase === "remux") return "Remux"; if (result.failurePhase === "cleanup") return "Aufräumen"; + if (result.failurePhase === "postprocess") return "Nachbearbeitung"; return "—"; } @@ -177,6 +179,7 @@ function event( function packageEventType(status: PackageResultStatus): NotificationEventType { if (status === "completed") return "package_completed"; if (status === "partial") return "package_partial"; + if (status === "cancelled") return "package_cancelled"; return "package_failed"; } @@ -199,7 +202,7 @@ export function buildPackageNotificationEvent( { 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: "Zeiten", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Nachbearbeitung ${formatDuration(result.postProcessDurationSeconds)} · 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 } ]; @@ -294,7 +297,8 @@ export function buildRunResult(input: RunResultInput): RunResult { remuxFailures: sum((result) => result.remuxFailures), downloadFailures: sum((result) => result.downloadFailures), offlineFailures: sum((result) => result.offlineFailures), - cleanupFailures: sum((result) => result.cleanupFailures) + cleanupFailures: sum((result) => result.cleanupFailures), + postProcessFailures: sum((result) => result.postProcessFailures) }; } @@ -326,7 +330,8 @@ export function buildRunNotificationEvent(result: RunResult): NotificationEvent { name: "Remuxfehler", value: String(result.remuxFailures), inline: true }, { name: "Downloadfehler", value: String(result.downloadFailures), inline: true }, { name: "Offline", value: String(result.offlineFailures), inline: true }, - { name: "Cleanupfehler", value: String(result.cleanupFailures), inline: true } + { name: "Cleanupfehler", value: String(result.cleanupFailures), inline: true }, + { name: "Nachbearbeitungsfehler", value: String(result.postProcessFailures), inline: true } ] ); } @@ -387,6 +392,13 @@ export function buildHistoryEntry( partCount: result.partCount, outputCount: result.outputCount, failurePhase: result.failurePhase, + errorCategory: result.errorCategory, + downloadFailures: result.downloadFailures, + offlineFailures: result.offlineFailures, + extractionFailures: result.extractionFailures, + remuxFailures: result.remuxFailures, + cleanupFailures: result.cleanupFailures, + postProcessFailures: result.postProcessFailures, archiveOperations: result.archiveOperations.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] })), remuxOperations: result.remuxOperations.map((operation) => ({ ...operation })) }; diff --git a/src/main/notification-outbox.ts b/src/main/notification-outbox.ts index 58ef579..4752a99 100644 --- a/src/main/notification-outbox.ts +++ b/src/main/notification-outbox.ts @@ -9,6 +9,7 @@ export type NotificationEventType = | "package_completed" | "package_partial" | "package_failed" + | "package_cancelled" | "run_completed" | "run_stopped" | "remaining_threshold_crossed" @@ -60,6 +61,7 @@ const EVENT_TYPES = new Set([ "package_completed", "package_partial", "package_failed", + "package_cancelled", "run_completed", "run_stopped", "remaining_threshold_crossed", @@ -71,13 +73,15 @@ const MAX_RETRY_DELAY_MS = 10 * 60 * 1000; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000; const PACKAGE_FAILURE_EVENT_TYPES = new Set([ "package_partial", - "package_failed" + "package_failed", + "package_cancelled" ]); const PACKAGE_FAILURE_PHASES = new Map([ ["Download", "download"], ["Entpacken", "extract"], ["Remux", "remux"], - ["Aufräumen", "cleanup"] + ["Aufräumen", "cleanup"], + ["Nachbearbeitung", "postprocess"] ]); function finiteInteger(value: unknown, fallback = 0): number { @@ -86,7 +90,7 @@ function finiteInteger(value: unknown, fallback = 0): number { } function sanitizePackageFailureFieldValue(value: string): string { - const match = /^(Download|Entpacken|Remux|Aufräumen)(?:\s*·\s*(.*))?$/s.exec(value.trim()); + const match = /^(Download|Entpacken|Remux|Aufräumen|Nachbearbeitung)(?:\s*·\s*(.*))?$/s.exec(value.trim()); if (!match) { return "Unbekannt"; } diff --git a/src/main/package-telemetry.ts b/src/main/package-telemetry.ts index 610cd05..08049c4 100644 --- a/src/main/package-telemetry.ts +++ b/src/main/package-telemetry.ts @@ -17,6 +17,7 @@ export type PackageFailureCategory = | "Entpacken" | "Remux" | "Cleanup" + | "Nachbearbeitung" | "Unbekannt"; const packageFailureCategories = new Map([ @@ -29,6 +30,7 @@ const packageFailureCategories = new Map([ ["entpacken", "Entpacken"], ["remux", "Remux"], ["cleanup", "Cleanup"], + ["nachbearbeitung", "Nachbearbeitung"], ["unbekannt", "Unbekannt"] ]); @@ -51,6 +53,34 @@ export function sumOperationDurationSeconds(operations: readonly { durationMs: n return durationMsToSeconds(operations.reduce((total, operation) => total + finiteNonNegative(operation.durationMs), 0)); } +export function unionOperationDurationSeconds(operations: readonly { startedAt: number; completedAt: number; durationMs: number }[]): number { + const intervals = operations + .map((operation) => ({ start: finiteNonNegative(operation.startedAt), end: finiteNonNegative(operation.completedAt) })) + .filter((interval) => interval.start > 0 && interval.end > interval.start) + .sort((left, right) => left.start - right.start || left.end - right.end); + let durationMs = operations + .filter((operation) => finiteNonNegative(operation.completedAt) <= finiteNonNegative(operation.startedAt)) + .reduce((total, operation) => total + finiteNonNegative(operation.durationMs), 0); + let currentStart = 0; + let currentEnd = 0; + for (const interval of intervals) { + if (currentEnd === 0) { + currentStart = interval.start; + currentEnd = interval.end; + } else if (interval.start <= currentEnd) { + currentEnd = Math.max(currentEnd, interval.end); + } else { + durationMs += currentEnd - currentStart; + currentStart = interval.start; + currentEnd = interval.end; + } + } + if (currentEnd > currentStart) { + durationMs += currentEnd - currentStart; + } + return durationMsToSeconds(durationMs); +} + export function projectPackageFailureCategory(failurePhase: FailurePhase, detail: unknown): PackageFailureCategory { const normalized = String(detail ?? "").trim().slice(0, 2048).toLowerCase(); const knownCategory = packageFailureCategories.get(normalized); @@ -76,17 +106,25 @@ export function projectPackageFailureCategory(failurePhase: FailurePhase, detail if (failurePhase === "extract") return "Entpacken"; if (failurePhase === "remux") return "Remux"; if (failurePhase === "cleanup") return "Cleanup"; + if (failurePhase === "postprocess") return "Nachbearbeitung"; return "Unbekannt"; } -function classifyStatus(successfulFiles: number, failedFiles: number, cancelledFiles: number, packageCancelled: boolean): PackageResultStatus { - if (successfulFiles > 0 && (failedFiles > 0 || cancelledFiles > 0 || packageCancelled)) { +function classifyStatus( + successfulFiles: number, + failedFiles: number, + cancelledFiles: number, + postProcessFailures: number, + postProcessCancellations: number, + packageCancelled: boolean +): PackageResultStatus { + if (successfulFiles > 0 && (failedFiles > 0 || cancelledFiles > 0 || postProcessFailures > 0 || postProcessCancellations > 0 || packageCancelled)) { return "partial"; } - if (failedFiles > 0) { + if (failedFiles > 0 || postProcessFailures > 0) { return "failed"; } - if (cancelledFiles > 0 || packageCancelled) { + if (cancelledFiles > 0 || postProcessCancellations > 0 || packageCancelled) { return "cancelled"; } return "completed"; @@ -94,6 +132,7 @@ function classifyStatus(successfulFiles: number, failedFiles: number, cancelledF function getFailure( cleanupErrorCategory: string, + postProcessErrorCategory: string, remuxOperations: readonly RemuxOperationMetric[], remuxFallbackFailures: number, archiveOperations: readonly ArchiveOperationMetric[], @@ -102,6 +141,9 @@ function getFailure( if (cleanupErrorCategory) { return { failurePhase: "cleanup", errorCategory: projectPackageFailureCategory("cleanup", cleanupErrorCategory) }; } + if (postProcessErrorCategory) { + return { failurePhase: "postprocess", errorCategory: projectPackageFailureCategory("postprocess", postProcessErrorCategory) }; + } const failedRemux = remuxOperations.find((operation) => operation.status === "failed"); if (failedRemux || remuxFallbackFailures > 0) { return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) }; @@ -134,16 +176,18 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul const audioStripFailures = Math.max(0, Math.floor(finiteNonNegative(packageEntry.audioStripSummary?.failed))); const remuxFailures = Math.max(failedRemuxOperations, audioStripFailures); const cleanupErrorCategory = String(telemetry.cleanupErrorCategory ?? packageEntry.cleanupErrorCategory ?? "").trim(); + const postProcessErrorCategory = String(telemetry.postProcessErrorCategory ?? packageEntry.postProcessErrorCategory ?? "").trim(); const downloadFailureCategories = failedDownloads.map((item) => projectPackageFailureCategory("download", item.lastError || item.fullStatus) ); const offlineFailures = downloadFailureCategories.filter((category) => category === "Offline").length; const cleanupFailures = cleanupErrorCategory ? 1 : 0; - const postProcessFailures = failedArchives + remuxFailures + cleanupFailures; + const postProcessFailures = postProcessErrorCategory ? 1 : 0; + const operationFailures = failedArchives + remuxFailures + cleanupFailures + postProcessFailures; const postProcessCancellations = cancelledArchives + cancelledRemuxOperations; - const failedFiles = failedDownloads.length + postProcessFailures; - const cancelledFiles = cancelledDownloads + postProcessCancellations; - const successfulFiles = Math.max(0, completedDownloads - postProcessFailures - postProcessCancellations); + const failedFiles = failedDownloads.length; + const cancelledFiles = cancelledDownloads; + const successfulFiles = completedDownloads; const startedAt = finiteNonNegative(packageEntry.downloadStartedAt); const downloadEndedAt = finiteNonNegative(packageEntry.downloadEndedAt) || finiteNonNegative(packageEntry.downloadCompletedAt); const postProcessStartedAt = finiteNonNegative(packageEntry.postProcessStartedAt); @@ -154,12 +198,13 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul const totalBytes = finiteNonNegative(packageEntry.cleanedTotalBytes) + telemetry.items.reduce((total, item) => total + finiteNonNegative(item.totalBytes ?? item.downloadedBytes), 0); const downloadDurationSeconds = durationSecondsBetween(startedAt, downloadEndedAt); - const extractionDurationSeconds = sumOperationDurationSeconds(archiveOperations); + const extractionDurationSeconds = unionOperationDurationSeconds(archiveOperations); const remuxDurationSeconds = sumOperationDurationSeconds(remuxOperations); const postProcessDurationSeconds = durationSecondsBetween(postProcessStartedAt, postProcessCompletedAt); const totalDurationSeconds = durationSecondsBetween(startedAt, completedAt); const failure = getFailure( cleanupErrorCategory, + postProcessErrorCategory, remuxOperations, audioStripFailures, archiveOperations, @@ -169,7 +214,7 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul return { packageId: packageEntry.id, name: packageEntry.name, - status: classifyStatus(successfulFiles, failedFiles, cancelledFiles, packageEntry.cancelled), + status: classifyStatus(successfulFiles, failedFiles, cancelledFiles, operationFailures, postProcessCancellations, packageEntry.cancelled), startedAt, downloadEndedAt, postProcessStartedAt, @@ -190,6 +235,7 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul extractionFailures: failedArchives, remuxFailures, cleanupFailures, + postProcessFailures, archiveCount: archiveOperations.length, partCount: archiveOperations.reduce((total, operation) => total + Math.max(0, Math.floor(finiteNonNegative(operation.partCount))), 0), outputCount: Math.max(0, Math.floor(finiteNonNegative(telemetry.outputCount ?? packageEntry.outputCount))), diff --git a/src/main/storage.ts b/src/main/storage.ts index c6f965a..41069e7 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -46,8 +46,9 @@ const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid" const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); const VALID_OPERATION_STATUSES = new Set(["completed", "failed", "cancelled"]); const VALID_HISTORY_STATUSES = new Set(["completed", "partial", "failed", "cancelled", "deleted"]); -const VALID_FAILURE_PHASES = new Set(["download", "extract", "remux", "cleanup"]); +const VALID_FAILURE_PHASES = new Set(["download", "extract", "remux", "cleanup", "postprocess"]); const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; +const SAFE_OUTPUT_SIGNATURE_RE = /^[a-f0-9]{64}$/; function asText(value: unknown): string { return String(value ?? "").trim(); @@ -1019,7 +1020,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState { archiveOperations: normalizeArchiveOperations(pkg.archiveOperations), remuxOperations: normalizeRemuxOperations(pkg.remuxOperations), outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000), + outputBaselineSignatures: Array.isArray(pkg.outputBaselineSignatures) + ? [...new Set(pkg.outputBaselineSignatures.map((value) => asText(value).toLowerCase()).filter((value) => SAFE_OUTPUT_SIGNATURE_RE.test(value)))].slice(0, 1_000_000) + : undefined, cleanupErrorCategory: normalizeFailureCategory("cleanup", pkg.cleanupErrorCategory), + postProcessErrorCategory: normalizeFailureCategory("postprocess", pkg.postProcessErrorCategory), resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) @@ -1607,7 +1612,13 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry cancelledFiles: optionalClampedNumber(entry, "cancelledFiles", 1_000_000), archiveCount: optionalClampedNumber(entry, "archiveCount", 100_000), partCount: optionalClampedNumber(entry, "partCount", 1_000_000), - outputCount: optionalClampedNumber(entry, "outputCount", 1_000_000) + outputCount: optionalClampedNumber(entry, "outputCount", 1_000_000), + downloadFailures: optionalClampedNumber(entry, "downloadFailures", 1_000_000), + offlineFailures: optionalClampedNumber(entry, "offlineFailures", 1_000_000), + extractionFailures: optionalClampedNumber(entry, "extractionFailures", 1_000_000), + remuxFailures: optionalClampedNumber(entry, "remuxFailures", 1_000_000), + cleanupFailures: optionalClampedNumber(entry, "cleanupFailures", 1_000_000), + postProcessFailures: optionalClampedNumber(entry, "postProcessFailures", 1_000_000) }; return { @@ -1626,6 +1637,9 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry ...(failurePhaseRaw === null || VALID_FAILURE_PHASES.has(failurePhaseRaw) ? { failurePhase: failurePhaseRaw as FailurePhase } : {}), + ...(asText(entry.errorCategory) + ? { errorCategory: normalizeFailureCategory(failurePhaseRaw !== null && VALID_FAILURE_PHASES.has(failurePhaseRaw) ? failurePhaseRaw as FailurePhase : null, entry.errorCategory) } + : {}), ...(Array.isArray(entry.archiveOperations) ? { archiveOperations: normalizeArchiveOperations(entry.archiveOperations) } : {}), ...(Array.isArray(entry.remuxOperations) ? { remuxOperations: normalizeRemuxOperations(entry.remuxOperations) } : {}) }; diff --git a/src/renderer/views/history/HistoryView.tsx b/src/renderer/views/history/HistoryView.tsx index 48c7641..8e32ce9 100644 --- a/src/renderer/views/history/HistoryView.tsx +++ b/src/renderer/views/history/HistoryView.tsx @@ -59,8 +59,10 @@ const filterItems: Array<{ id: HistoryFilter; label: string }> = [ { id: "today", label: "Heute" }, { id: "week", label: "Letzte 7 Tage" }, { id: "older", label: "Älter" }, - { id: "completed", label: "Fertig" }, - { id: "deleted", label: "Gelöscht" }, + { id: "completed", label: "Fertig" }, + { id: "partial", label: "Teilweise" }, + { id: "cancelled", label: "Abgebrochen" }, + { id: "deleted", label: "Gelöscht" }, { id: "failed", label: "Fehlgeschlagen" } ]; @@ -205,6 +207,8 @@ function HistoryRowDetails({
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}
+
Fehlerkategorie
{row.errorCategory || "—"}
+
Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung
{row.failureCountsLabel}
) : (
Downloaddauer (Altbestand)
{row.durationLabel}
diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index 2d96725..96fc48f 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -1,6 +1,6 @@ import type { DebridProvider, HistoryEntry } from "../../../shared/types"; -export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed"; +export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "partial" | "failed" | "cancelled" | "deleted"; export type HistoryViewStatus = HistoryEntry["status"] | "failed"; export type HistoryViewEntry = Omit & { status: HistoryViewStatus }; @@ -23,6 +23,7 @@ export interface HistoryRow extends HistoryViewEntry { postProcessDurationLabel: string; totalDurationLabel: string; failurePhaseLabel: string; + failureCountsLabel: string; } export interface HistoryFilterCounts { @@ -31,6 +32,8 @@ export interface HistoryFilterCounts { week: number; older: number; completed: number; + partial: number; + cancelled: number; deleted: number; failed: number; } @@ -178,9 +181,24 @@ function failurePhaseLabel(entry: HistoryViewEntry): string { if (entry.failurePhase === "extract") return "Entpacken"; if (entry.failurePhase === "remux") return "Remux"; if (entry.failurePhase === "cleanup") return "Aufräumen"; + if (entry.failurePhase === "postprocess") return "Nachbearbeitung"; return "—"; } +function failureCountsLabel(entry: HistoryViewEntry): string { + const values = [ + entry.downloadFailures, + entry.offlineFailures, + entry.extractionFailures, + entry.remuxFailures, + entry.cleanupFailures, + entry.postProcessFailures + ]; + return values.every((value) => value === undefined) + ? "—" + : values.map((value) => Math.max(0, Number(value) || 0)).join(" / "); +} + export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage { const totalItems = rows.length; const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE)); @@ -209,7 +227,7 @@ function localDayStart(timestamp: number): number { } function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, now: number): boolean { - if (filter === "completed" || filter === "deleted" || filter === "failed") { + if (filter === "completed" || filter === "partial" || filter === "failed" || filter === "cancelled" || filter === "deleted") { return entry.status === filter; } if (filter === "all") { @@ -296,7 +314,8 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow { remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0), postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0), totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0), - failurePhaseLabel: failurePhaseLabel(entry) + failurePhaseLabel: failurePhaseLabel(entry), + failureCountsLabel: failureCountsLabel(entry) }; } @@ -332,6 +351,8 @@ function countHistoryFilters(entries: HistoryViewEntry[], now: number): HistoryF week: entries.filter((entry) => matchesTemporalFilter(entry, "week", now)).length, older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length, completed: entries.filter((entry) => entry.status === "completed").length, + partial: entries.filter((entry) => entry.status === "partial").length, + cancelled: entries.filter((entry) => entry.status === "cancelled").length, deleted: entries.filter((entry) => entry.status === "deleted").length, failed: entries.filter((entry) => entry.status === "failed").length }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 52e8be1..12e77df 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -503,7 +503,7 @@ export interface AudioStripSummary { } export type PackageResultStatus = "completed" | "partial" | "failed" | "cancelled"; -export type FailurePhase = "download" | "extract" | "remux" | "cleanup" | null; +export type FailurePhase = "download" | "extract" | "remux" | "cleanup" | "postprocess" | null; export interface ArchiveOperationMetric { id: string; @@ -534,6 +534,7 @@ export interface PackageTelemetry { remuxOperations?: RemuxOperationMetric[]; outputCount?: number; cleanupErrorCategory?: string; + postProcessErrorCategory?: string; } export interface PackageResult { @@ -560,6 +561,7 @@ export interface PackageResult { extractionFailures: number; remuxFailures: number; cleanupFailures: number; + postProcessFailures: number; archiveCount: number; partCount: number; outputCount: number; @@ -597,7 +599,9 @@ export interface PackageEntry { archiveOperations?: ArchiveOperationMetric[]; remuxOperations?: RemuxOperationMetric[]; outputCount?: number; + outputBaselineSignatures?: string[]; cleanupErrorCategory?: string; + postProcessErrorCategory?: string; resultGeneration?: number; createdAt: number; updatedAt: number; @@ -945,6 +949,13 @@ export interface HistoryEntry { partCount?: number; outputCount?: number; failurePhase?: FailurePhase; + errorCategory?: string; + downloadFailures?: number; + offlineFailures?: number; + extractionFailures?: number; + remuxFailures?: number; + cleanupFailures?: number; + postProcessFailures?: number; archiveOperations?: ArchiveOperationMetric[]; remuxOperations?: RemuxOperationMetric[]; } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 9983f83..7186e20 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -14,7 +14,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; -import { createStoragePaths, emptySession } from "../src/main/storage"; +import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage"; import { loadStatisticsLedger } from "../src/main/statistics-ledger"; import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; @@ -23,6 +23,7 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/ import { UnrestrictedLink } from "../src/main/realdebrid"; import { resetVideoToolingCache } from "../src/main/video-processor"; import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor"; +import { finalizePackageResult } from "../src/main/package-telemetry"; import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types"; const tempDirs: string[] = []; @@ -11991,7 +11992,7 @@ describe("download manager", () => { expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false); }, 20000); - it("moves extracted MKV files into a flat library folder per completed package", async () => { + it("moves extracted MKV files into a flat library folder per completed package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -12024,7 +12025,72 @@ describe("download manager", () => { expect(manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); expect(fs.existsSync(flattenedPath)).toBe(true); expect(fs.existsSync(originalExtractedPath)).toBe(false); - }, 20000); + }, 20000); + + it("records a real library move failure as post-processing telemetry", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-library-move-failure-")); + tempDirs.push(root); + const outputDir = path.join(root, "downloads", "package"); + const libraryDir = path.join(root, "library"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, "episode.mkv"), "video", "utf8"); + const session = emptySession(); + const packageId = "library-move-failure"; + const itemId = "library-move-failure-item"; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Library move failure", + outputDir, + extractDir: path.join(root, "extract"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + downloadStartedAt: 1_000, + downloadEndedAt: 2_000, + postProcessStartedAt: 2_000, + postProcessCompletedAt: 3_000, + terminalAt: 3_000, + createdAt: 1_000, + updatedAt: 3_000 + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://example.test/episode.mkv", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 5, + totalBytes: 5, + progressPercent: 100, + fileName: "episode.mkv", + targetPath: path.join(outputDir, "episode.mkv"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt: 1_000, + updatedAt: 2_000 + }; + const manager = new DownloadManager({ + ...defaultSettings(), + autoExtract: false, + collectMkvToLibrary: true, + mkvLibraryDir: libraryDir + }, session, createStoragePaths(path.join(root, "state"))); + const state = manager as any; + vi.spyOn(state, "moveFileWithExdevFallback").mockRejectedValue(new Error("move denied")); + const pkg = state.session.packages[packageId] as PackageEntry; + + await state.collectMkvFilesToLibrary(packageId, pkg); + const result = finalizePackageResult({ package: pkg, items: [state.session.items[itemId]] }); + + expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung"); + expect(result).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 }); + }); it("moves extracted AVI files into a flat library folder per completed package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -14592,6 +14658,344 @@ describe("package lifecycle telemetry boundaries", () => { expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]); }); + it("terminalizes a started archive as cancelled with the validated part count", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-cancelled-metric-")); + tempDirs.push(root); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { + id: "archive-cancelled-package", + name: "Archive cancelled", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + status: "extracting", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + const state = manager as unknown as { + recordArchiveOperation: (entry: PackageEntry, update: Record, items: DownloadItem[], errorCategory: string, partCount: number) => void; + finalizeActiveArchiveOperations: (entry: PackageEntry) => void; + }; + + state.recordArchiveOperation(pkg, { + current: 0, + total: 1, + percent: 0, + archiveName: "show.part01.rar", + archivePercent: 0, + elapsedMs: 0, + archiveDone: false + }, [], "", 16); + state.finalizeActiveArchiveOperations(pkg); + + expect(pkg.archiveOperations).toEqual([ + expect.objectContaining({ + name: "show.part01.rar", + partCount: 16, + status: "cancelled" + }) + ]); + }); + + it("replaces an unresolved archive start with its terminal completion", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-terminal-metric-")); + tempDirs.push(root); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { + id: "archive-terminal-package", + name: "Archive terminal", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + status: "extracting", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + const state = manager as unknown as { + recordArchiveOperation: (entry: PackageEntry, update: Record, items: DownloadItem[], errorCategory: string, partCount: number) => void; + finalizeActiveArchiveOperations: (entry: PackageEntry) => void; + }; + const start = { + current: 0, + total: 1, + percent: 0, + archiveName: "nested.rar", + archivePercent: 0, + elapsedMs: 0, + archiveDone: false + }; + + state.recordArchiveOperation(pkg, start, [], "", 1); + state.recordArchiveOperation(pkg, { ...start, current: 1, percent: 100, archivePercent: 100, elapsedMs: 1_000, archiveDone: true, archiveSuccess: true }, [], "", 1); + state.finalizeActiveArchiveOperations(pkg); + + expect(pkg.archiveOperations).toEqual([ + expect.objectContaining({ name: "nested.rar", partCount: 1, status: "completed" }) + ]); + }); + + it("keeps parallel unresolved archives stable by archive path when completions arrive in reverse order", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-parallel-metric-")); + tempDirs.push(root); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { + id: "archive-parallel-package", + name: "Archive parallel", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + status: "extracting", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + const state = manager as unknown as { + recordArchiveOperation: (entry: PackageEntry, update: Record, items: DownloadItem[], errorCategory: string, partCount: number) => void; + finalizeActiveArchiveOperations: (entry: PackageEntry) => void; + }; + const progress = (archivePath: string, current: number, archiveDone: boolean) => ({ + current, + total: 2, + percent: archiveDone ? 100 : 0, + archiveName: "same.rar", + archivePath, + archivePercent: archiveDone ? 100 : 0, + elapsedMs: archiveDone ? 1_000 : 0, + archiveDone, + archiveSuccess: archiveDone + }); + + state.recordArchiveOperation(pkg, progress(path.join(root, "a", "same.rar"), 0, false), [], "", 2); + state.recordArchiveOperation(pkg, progress(path.join(root, "b", "same.rar"), 0, false), [], "", 3); + expect(pkg.archiveOperations).toHaveLength(2); + expect(new Set(pkg.archiveOperations?.map((operation) => operation.id))).toHaveLength(2); + + state.recordArchiveOperation(pkg, progress(path.join(root, "b", "same.rar"), 1, true), [], "", 3); + state.recordArchiveOperation(pkg, progress(path.join(root, "a", "same.rar"), 2, true), [], "", 2); + state.finalizeActiveArchiveOperations(pkg); + + expect(pkg.archiveOperations).toHaveLength(2); + expect(pkg.archiveOperations?.map((operation) => operation.status)).toEqual(["completed", "completed"]); + expect(pkg.archiveOperations?.map((operation) => operation.partCount).sort((a, b) => a - b)).toEqual([2, 3]); + }); + + it("persists an archive start as a cancelled fallback before completion", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-restart-metric-")); + tempDirs.push(root); + const paths = createStoragePaths(path.join(root, "state")); + const session = emptySession(); + const manager = new DownloadManager(defaultSettings(), session, paths); + const pkg: PackageEntry = { + id: "archive-restart-package", + name: "Archive restart", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + status: "extracting", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + session.packageOrder = [pkg.id]; + session.packages[pkg.id] = pkg; + const state = manager as unknown as { + recordArchiveOperation: (entry: PackageEntry, update: Record, items: DownloadItem[], errorCategory: string, partCount: number) => void; + }; + + state.recordArchiveOperation(pkg, { + current: 0, + total: 1, + percent: 0, + archiveName: "restart.rar", + archivePath: path.join(root, "restart.rar"), + archivePercent: 0, + elapsedMs: 0, + archiveDone: false + }, [], "", 4); + saveSession(paths, session); + + expect(loadSession(paths).packages[pkg.id].archiveOperations).toEqual([ + expect.objectContaining({ name: "restart.rar", partCount: 4, status: "cancelled" }) + ]); + }); + + it("restarts a persisted cancelled archive fallback with fresh timing before a second abort", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-second-abort-")); + tempDirs.push(root); + const paths = createStoragePaths(path.join(root, "state")); + const session = emptySession(); + const packageId = "archive-second-abort"; + const archivePath = path.join(root, "restart.rar"); + session.packageOrder = [packageId]; + session.packages[packageId] = { id: packageId, name: "Second abort", outputDir: root, extractDir: root, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 }; + const firstManager = new DownloadManager(defaultSettings(), session, paths); + const firstState = firstManager as any; + const firstPackage = firstState.session.packages[packageId] as PackageEntry; + const progress = { current: 0, total: 1, percent: 0, archiveName: "restart.rar", archivePath, archivePercent: 0, elapsedMs: 0, archiveDone: false }; + + firstState.recordArchiveOperation(firstPackage, progress, [], "", 2); + saveSession(paths, firstState.session); + const firstStartedAt = loadSession(paths).packages[packageId].archiveOperations?.[0].startedAt || 0; + await new Promise((resolve) => setTimeout(resolve, 5)); + + const secondManager = new DownloadManager(defaultSettings(), loadSession(paths), paths); + const secondState = secondManager as any; + const secondPackage = secondState.session.packages[packageId] as PackageEntry; + secondState.recordArchiveOperation(secondPackage, progress, [], "", 2); + const secondStartedAt = secondPackage.archiveOperations?.[0].startedAt || 0; + await new Promise((resolve) => setTimeout(resolve, 5)); + secondState.finalizeActiveArchiveOperations(secondPackage); + + expect(secondStartedAt).toBeGreaterThan(firstStartedAt); + expect(secondPackage.archiveOperations).toEqual([ + expect.objectContaining({ status: "cancelled", startedAt: secondStartedAt }) + ]); + expect(secondPackage.archiveOperations?.[0].completedAt).toBeGreaterThan(secondStartedAt); + expect(secondPackage.archiveOperations?.[0].durationMs).toBeGreaterThan(0); + }); + + it("removes a cancelled archive fallback when the extractor skips a non-archive generic split", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-skipped-metric-")); + tempDirs.push(root); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { id: "skipped", name: "Skipped", outputDir: root, extractDir: root, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 }; + const state = manager as any; + const archivePath = path.join(root, "data.001"); + const start = { current: 0, total: 1, percent: 0, archiveName: "data.001", archivePath, archivePercent: 0, elapsedMs: 0, archiveDone: false }; + + state.recordArchiveOperation(pkg, start, [], "", 3); + state.recordArchiveOperation(pkg, { ...start, current: 1, percent: 100, archiveDone: true, archiveSkipped: true }, [], "", 3); + state.finalizeActiveArchiveOperations(pkg); + const result = finalizePackageResult({ package: { ...pkg, terminalAt: 2, downloadStartedAt: 1, downloadEndedAt: 2 }, items: [] }); + + expect(pkg.archiveOperations).toEqual([]); + expect(result).toMatchObject({ status: "completed", archiveCount: 0, partCount: 0, extractionFailures: 0 }); + }); + + it("counts only outputs created after the package generation baseline", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-generation-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "old-output.mkv"), "old", "utf8"); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { + id: "output-generation-package", + name: "Output generation", + outputDir: path.join(root, "downloads"), + extractDir, + status: "extracting", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }; + const state = manager as unknown as { + capturePackageOutputBaseline: (entry: PackageEntry) => Promise; + refreshPackageOutputCount: (entry: PackageEntry) => Promise; + }; + + await state.capturePackageOutputBaseline(pkg); + fs.writeFileSync(path.join(extractDir, "new-output.mkv"), "new", "utf8"); + await state.refreshPackageOutputCount(pkg); + + expect(pkg.outputBaselineSignatures).toHaveLength(1); + expect(pkg.outputBaselineSignatures?.[0]).toMatch(/^[a-f0-9]{64}$/); + expect(JSON.stringify(pkg.outputBaselineSignatures)).not.toContain("old-output.mkv"); + expect(pkg.outputCount).toBe(1); + }); + + it("counts an overwritten baseline path as a generation output", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-overwrite-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + const outputPath = path.join(extractDir, "episode.mkv"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(outputPath, "old", "utf8"); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { id: "overwrite", name: "Overwrite", outputDir: root, extractDir, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 }; + const state = manager as any; + + await state.capturePackageOutputBaseline(pkg); + fs.writeFileSync(outputPath, "new-content", "utf8"); + await state.refreshPackageOutputCount(pkg); + + expect(pkg.outputCount).toBe(1); + }); + + it("counts a new output when a baseline file is deleted in parallel", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-replaced-")); + tempDirs.push(root); + const extractDir = path.join(root, "extract"); + const oldPath = path.join(extractDir, "old.mkv"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(oldPath, "old", "utf8"); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const pkg: PackageEntry = { id: "replaced", name: "Replaced", outputDir: root, extractDir, status: "extracting", itemIds: [], cancelled: false, enabled: true, createdAt: 1, updatedAt: 1 }; + const state = manager as any; + + await state.capturePackageOutputBaseline(pkg); + fs.rmSync(oldPath); + fs.writeFileSync(path.join(extractDir, "new.mkv"), "new", "utf8"); + await state.refreshPackageOutputCount(pkg); + + expect(pkg.outputCount).toBe(1); + }); + + it("counts nested multipart files from the candidate directory only", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-parts-")); + tempDirs.push(root); + const nestedDir = path.join(root, "season"); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(path.join(root, "show.001"), "root", "utf8"); + fs.writeFileSync(path.join(root, "show.002"), "root", "utf8"); + fs.writeFileSync(path.join(nestedDir, "show.001"), "nested", "utf8"); + fs.writeFileSync(path.join(nestedDir, "show.002"), "nested", "utf8"); + fs.writeFileSync(path.join(nestedDir, "show.003"), "nested", "utf8"); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const state = manager as unknown as { resolveArchivePartCount: (archivePath: string) => Promise }; + + expect(await state.resolveArchivePartCount(path.join(nestedDir, "show.001"))).toBe(3); + }); + + it("keeps cleanup and generic post-processing failures in separate phases", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-phase-")); + tempDirs.push(root); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + const makePackage = (id: string): PackageEntry => ({ + id, + name: id, + outputDir: path.join(root, "downloads", id), + extractDir: path.join(root, "extract", id), + status: "failed", + itemIds: [], + cancelled: false, + enabled: true, + createdAt: 1_000, + updatedAt: 1_000 + }); + const cleanupPackage = makePackage("cleanup-package"); + const postProcessPackage = makePackage("postprocess-package"); + const state = manager as unknown as { + recordPostProcessFailure: (entry: PackageEntry, phase: "cleanup" | "postprocess", error: unknown) => void; + }; + + state.recordPostProcessFailure(cleanupPackage, "cleanup", new Error("ENOSPC C:\\Private\\archive.rar")); + state.recordPostProcessFailure(postProcessPackage, "postprocess", new Error("rename C:\\Private\\episode.mkv")); + + expect(cleanupPackage.cleanupErrorCategory).toBe("Speicherplatz"); + expect(cleanupPackage.postProcessErrorCategory).toBeUndefined(); + expect(postProcessPackage.cleanupErrorCategory).toBeUndefined(); + expect(postProcessPackage.postProcessErrorCategory).toBe("Nachbearbeitung"); + }); + it("projects archive failure details before storing operation telemetry", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-failure-category-")); tempDirs.push(root); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 3dec775..05cc451 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -152,7 +152,7 @@ describe("extractor", () => { expect(targets.has(other)).toBe(false); }); - it("extracts archives in natural episode order", async () => { + it("extracts archives in natural episode order", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); @@ -194,8 +194,38 @@ describe("extractor", () => { "Show.S01E01.zip", "Show.S01E02.zip", "Show.S01E10.zip" - ]); - }); + ]); + }); + + it("includes the normalized archive path in archive progress updates", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-progress-path-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + const archivePath = path.join(packageDir, "episode.zip"); + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("episode")); + zip.writeZip(archivePath); + const progressPaths: string[] = []; + + await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onProgress: (update) => { + if (update.archiveName === "episode.zip") { + progressPaths.push(String(update.archivePath || "")); + } + } + }); + + expect(progressPaths.length).toBeGreaterThan(0); + expect(new Set(progressPaths)).toEqual(new Set([path.resolve(archivePath)])); + }); it("deletes split zip companion parts when cleanup is enabled", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); @@ -849,7 +879,7 @@ describe("extractor", () => { expect(targets.has(other)).toBe(false); }); - it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => { + it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-noarch-")); tempDirs.push(root); const packageDir = path.join(root, "pkg"); @@ -880,9 +910,41 @@ describe("extractor", () => { expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); expect(fs.existsSync(d001)).toBe(true); expect(fs.existsSync(d002)).toBe(true); - expect(fs.existsSync(d003)).toBe(true); - }); - }); + expect(fs.existsSync(d003)).toBe(true); + }); + + it("emits terminal skipped progress for a generic split without an archive signature", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-skip-progress-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + const archivePath = path.join(packageDir, "data.001"); + fs.writeFileSync(archivePath, "plain data without archive signature", "utf8"); + const progress: Array<{ archiveDone?: boolean; archiveSkipped?: boolean; archivePath?: string }> = []; + + await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onlyArchives: new Set([process.platform === "win32" ? path.resolve(archivePath).toLowerCase() : path.resolve(archivePath)]), + onProgress: (update) => { + if (update.archiveName === "data.001") { + progress.push(update); + } + } + }); + + expect(progress.at(-1)).toMatchObject({ + archiveDone: true, + archiveSkipped: true, + archivePath: path.resolve(archivePath) + }); + }); + }); describe("detectArchiveSignature", () => { it("detects RAR signature", async () => { diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index 0a3e2dc..8ca64ea 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -125,9 +125,11 @@ describe("history model", () => { all: ["today", "week-edge", "week", "older"], today: ["today"], week: ["week-edge", "week"], - older: ["older"], - completed: ["today", "week-edge"], - deleted: ["week"], + older: ["older"], + completed: ["today", "week-edge"], + partial: [], + cancelled: [], + deleted: ["week"], failed: ["older"] }; @@ -145,6 +147,28 @@ describe("history model", () => { expect(rows.map((row) => row.statusLabel)).toEqual(["Teilweise", "Abgebrochen"]); }); + it("does not invent zero failure counts for legacy history", () => { + const legacy = entry({ + id: "legacy-structured", + name: "Legacy structured", + startedAt: todayStart, + totalDurationSeconds: 60 + }); + + expect(filterHistoryRows([legacy], "all", "", now)[0].failureCountsLabel).toBe("—"); + }); + + it("filters partial and cancelled package results independently", () => { + const values = [ + entry({ id: "partial", name: "Teilweise", status: "partial" }), + entry({ id: "cancelled", name: "Abgebrochen", status: "cancelled" }), + entry({ id: "failed", name: "Fehlgeschlagen", status: "failed" }) + ]; + + expect(filterHistoryRows(values, "partial", "", now).map((row) => row.id)).toEqual(["partial"]); + expect(filterHistoryRows(values, "cancelled", "", now).map((row) => row.id)).toEqual(["cancelled"]); + }); + it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => { const springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime(); const springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime(); @@ -378,7 +402,7 @@ describe("HistoryView", () => { const html = renderToStaticMarkup(); expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical"); - expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(7); + expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(9); expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1); }); @@ -632,6 +656,13 @@ describe("HistoryView", () => { partCount: 16, outputCount: 10, failurePhase: "remux", + errorCategory: "Berechtigung", + downloadFailures: 1, + offlineFailures: 1, + extractionFailures: 2, + remuxFailures: 3, + cleanupFailures: 4, + postProcessFailures: 5, archiveOperations: [{ id: "archive-1", name: "show.part01.rar", @@ -673,6 +704,8 @@ describe("HistoryView", () => { "Erfolgreich / Fehlgeschlagen / Abgebrochen", "Archive / Parts / Ausgaben", "Fehlerphase", + "Fehlerkategorie", + "Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung", "Archivvorgänge", "Remuxvorgänge" ]) { @@ -682,6 +715,8 @@ describe("HistoryView", () => { expect(html).toContain("16 Parts"); expect(html).toContain("episode.mkv"); expect(html).toContain("ffmpeg"); + expect(html).toContain("Berechtigung"); + expect(html).toContain("1 / 1 / 2 / 3 / 4 / 5"); expect(html).not.toContain("Downloaddauer (Altbestand)"); }); diff --git a/tests/notification-outbox.test.ts b/tests/notification-outbox.test.ts index 39d8c50..45dbbde 100644 --- a/tests/notification-outbox.test.ts +++ b/tests/notification-outbox.test.ts @@ -586,6 +586,7 @@ describe("NotificationOutbox", () => { extractionFailures: 0, remuxFailures: 0, cleanupFailures: 0, + postProcessFailures: 0, archiveCount: 0, partCount: 0, outputCount: 0, @@ -623,6 +624,77 @@ describe("NotificationOutbox", () => { } }); + it("keeps the fixed post-processing phase while sanitizing a persisted package failure", async () => { + const filePath = createOutboxFile(); + fs.writeFileSync(filePath, JSON.stringify({ + version: 1, + events: [event("postprocess-private", { + payload: { + title: "Paket teilweise fertig", + fields: [{ name: "Fehler", value: `Nachbearbeitung · ${privateFailureDetails}`, inline: false }] + } + })], + lastSuccessAt: 0, + lastFailureAt: 0 + }), "utf8"); + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); + + await outbox.enqueue(event("safe")); + + expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Nachbearbeitung · Nachbearbeitung"); + expect(fs.readFileSync(filePath, "utf8")).not.toContain(privateFailureDetails); + }); + + it("includes post-processing duration in package notifications", () => { + const result = finalizePackageResult({ + ...privateFailureTelemetry("cleanup"), + cleanupErrorCategory: "", + postProcessErrorCategory: "rename failed", + package: { + ...privateFailureTelemetry("cleanup").package, + postProcessStartedAt: 3_000, + postProcessCompletedAt: 10_000, + terminalAt: 10_000 + } + }); + + const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 10_000); + + expect(notificationEvent.payload.fields.find((field) => field.name === "Zeiten")?.value).toContain("Nachbearbeitung 0:07"); + }); + + it("persists cancelled package events without coercing them to failures", async () => { + const filePath = createOutboxFile(); + const result = finalizePackageResult({ + ...privateFailureTelemetry("fullStatus"), + package: { ...privateFailureTelemetry("fullStatus").package, cancelled: true }, + items: [{ ...privateFailureTelemetry("fullStatus").items[0], status: "cancelled", lastError: "", fullStatus: "Abgebrochen" }] + }); + const cancelledEvent = buildPackageNotificationEvent({ generation: 1, result }, 3_000); + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1_000 }); + + await outbox.enqueue(cancelledEvent); + + expect(cancelledEvent.type).toBe("package_cancelled"); + expect(persisted(filePath).events[0].type).toBe("package_cancelled"); + }); + + it("sanitizes unexpected failure details on persisted cancelled package events", async () => { + const filePath = createOutboxFile(); + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1_000 }); + + await outbox.enqueue(event("cancelled-private", { + type: "package_cancelled", + payload: { + title: "Paket abgebrochen", + fields: [{ name: "Fehler", value: `Nachbearbeitung · ${privateFailureDetails}`, inline: false }] + } + })); + + expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Nachbearbeitung · Nachbearbeitung"); + expect(fs.readFileSync(filePath, "utf8")).not.toContain(privateFailureDetails); + }); + it.each([ ["fullStatus fallback", "fullStatus", "Download · Download"], ["remux operation", "remux", "Remux · Remux"], diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 9aecbc7..8e7bc51 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -208,9 +208,17 @@ describe("authoritative package completion", () => { state.tryFinalizePackageResult?.(pkg.id); await flushNotifications(); - expect(events.map((event) => event.type)).toEqual(["package_failed"]); + expect(events.map((event) => event.type)).toEqual(["package_partial"]); expect(events[0].priority).toBe("error"); - expect(history[0]).toMatchObject({ status: "failed", failurePhase: "remux", failedFiles: 1 }); + expect(history[0]).toMatchObject({ + status: "partial", + failurePhase: "remux", + errorCategory: "Remux", + successfulFiles: 1, + failedFiles: 0, + remuxFailures: 1, + postProcessFailures: 0 + }); }); it("emits a partial package result when the terminal downloads are mixed", async () => { @@ -224,7 +232,67 @@ describe("authoritative package completion", () => { expect(pkg.status).toBe("failed"); expect(events.map((event) => event.type)).toEqual(["package_partial"]); - expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 }); + expect(history[0]).toMatchObject({ + status: "partial", + successfulFiles: 1, + failedFiles: 1, + downloadFailures: 1, + extractionFailures: 0, + remuxFailures: 0, + cleanupFailures: 0, + postProcessFailures: 0 + }); + }); + + it("turns a hybrid post-processing exception into a partial package result", async () => { + const { manager, session, events, history } = setup({ autoExtract: true }); + const pkg = addPackage(session); + const state = internal(manager); + state.runPackageIds.add(pkg.id); + state.handlePackagePostProcessing = async () => { + throw new Error("hybrid failed"); + }; + + await state.runPackagePostProcessing(pkg.id); + await flushNotifications(); + + expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung"); + expect(events.map((event) => event.type)).toEqual(["package_partial"]); + expect(history[0]).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 }); + }); + + it("turns a rename failure into a post-processing package result", async () => { + const { manager, session, history } = setup({ autoExtract: true }); + const pkg = addPackage(session); + pkg.status = "completed"; + const state = internal(manager); + state.runPackageIds.add(pkg.id); + state.autoRenameExtractedVideoFiles = async () => { + throw new Error("rename failed"); + }; + + await state.runDeferredPostExtraction(pkg.id, pkg, 1, 0, false, 1); + await flushNotifications(); + + expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung"); + expect(history[0]).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 }); + }); + + it("turns a library move failure into a post-processing package result", async () => { + const { manager, session, history } = setup(); + const pkg = addPackage(session); + pkg.status = "completed"; + const state = internal(manager); + state.runPackageIds.add(pkg.id); + state.collectMkvFilesToLibrary = async () => { + throw new Error("library move failed"); + }; + + await state.runDeferredPostExtraction(pkg.id, pkg, 1, 0, false, 0); + await flushNotifications(); + + expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung"); + expect(history[0]).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 }); }); it("creates a new result generation when extraction is retried", async () => { @@ -463,10 +531,10 @@ describe("authoritative run completion", () => { state.tryFinalizePackageResult?.(pkg.id); await flushNotifications(); - expect(events.map((event) => event.type)).toEqual(["package_failed", "run_completed"]); + expect(events.map((event) => event.type)).toEqual(["package_partial", "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); + expect(runEvent.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true); }); it("flushes successful package digests before run_completed", async () => { diff --git a/tests/package-telemetry.test.ts b/tests/package-telemetry.test.ts index 9cbd4b0..dd04dfd 100644 --- a/tests/package-telemetry.test.ts +++ b/tests/package-telemetry.test.ts @@ -131,7 +131,35 @@ describe("package lifecycle telemetry", () => { })); }); - it("counts a failed 16-part archive as one failed file and produces a partial result", () => { + it("uses the union of parallel extraction intervals", () => { + const items = [downloadItem("item-1"), downloadItem("item-2")]; + const result = finalizePackageResult(telemetry({ + package: packageEntry({ itemIds: items.map((item) => item.id) }), + items, + archiveOperations: [ + archiveOperation({ id: "archive-a", startedAt: 130_000, completedAt: 160_000, durationMs: 30_000 }), + archiveOperation({ id: "archive-b", startedAt: 130_000, completedAt: 160_000, durationMs: 30_000 }) + ] + })); + + expect(result.extractionDurationSeconds).toBe(30); + }); + + it("adds non-overlapping extraction intervals", () => { + const items = [downloadItem("item-1"), downloadItem("item-2")]; + const result = finalizePackageResult(telemetry({ + package: packageEntry({ itemIds: items.map((item) => item.id) }), + items, + archiveOperations: [ + archiveOperation({ id: "archive-a", startedAt: 100_000, completedAt: 130_000, durationMs: 30_000 }), + archiveOperation({ id: "archive-b", startedAt: 130_000, completedAt: 160_000, durationMs: 30_000 }) + ] + })); + + expect(result.extractionDurationSeconds).toBe(60); + }); + + it("keeps download file counts independent from a failed multipart archive", () => { const items = Array.from({ length: 16 }, (_, index) => downloadItem(`item-${index + 1}`)); const result = finalizePackageResult(telemetry({ package: packageEntry({ itemIds: items.map((item) => item.id) }), @@ -149,15 +177,32 @@ describe("package lifecycle telemetry", () => { downloadDurationSeconds: 120, extractionDurationSeconds: 30, totalDurationSeconds: 165, - successfulFiles: 15, - failedFiles: 1, + successfulFiles: 16, + failedFiles: 0, partCount: 16, archiveCount: 1, + extractionFailures: 1, failurePhase: "extract", errorCategory: "Entpacken" })); }); + it("classifies a generic post-processing failure separately from cleanup", () => { + const result = finalizePackageResult(telemetry({ + postProcessErrorCategory: "rename failed" + })); + + expect(result).toEqual(expect.objectContaining({ + status: "partial", + successfulFiles: 1, + failedFiles: 0, + cleanupFailures: 0, + postProcessFailures: 1, + failurePhase: "postprocess", + errorCategory: "Nachbearbeitung" + })); + }); + it("classifies a package with no successful files and a download failure as failed", () => { const item = downloadItem("item-1", "failed"); const result = finalizePackageResult(telemetry({ @@ -396,8 +441,8 @@ describe("package lifecycle telemetry", () => { expect(result).toEqual(expect.objectContaining({ status: "partial", - successfulFiles: 1, - failedFiles: 1, + successfulFiles: 2, + failedFiles: 0, failurePhase: "remux" })); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index ff93e6f..65a362b 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1235,6 +1235,13 @@ describe("settings storage", () => { partCount: 16, outputCount: 15, failurePhase: "extract", + errorCategory: "checksum", + downloadFailures: 2, + offlineFailures: 1, + extractionFailures: 3, + remuxFailures: 4, + cleanupFailures: 5, + postProcessFailures: 6, archiveOperations: [{ id: "archive-1", name: "Paket.part01.rar", @@ -1274,6 +1281,13 @@ describe("settings storage", () => { partCount: 16, outputCount: 15, failurePhase: "extract", + errorCategory: "Entpacken", + downloadFailures: 2, + offlineFailures: 1, + extractionFailures: 3, + remuxFailures: 4, + cleanupFailures: 5, + postProcessFailures: 6, archiveOperations: [{ id: "archive-1", name: "Paket.part01.rar", @@ -1332,7 +1346,13 @@ describe("settings storage", () => { }], remuxOperations: [], outputCount: 1, + outputBaselineSignatures: [ + "a".repeat(64), + "invalid", + "b".repeat(64) + ], cleanupErrorCategory: "", + postProcessErrorCategory: "rename failed", createdAt: 1_000, updatedAt: 21_000 } @@ -1358,7 +1378,9 @@ describe("settings storage", () => { archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })], remuxOperations: [], outputCount: 1, - cleanupErrorCategory: "" + outputBaselineSignatures: ["a".repeat(64), "b".repeat(64)], + cleanupErrorCategory: "", + postProcessErrorCategory: "Nachbearbeitung" })); });