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" })); });