feat(history): complete package lifecycle telemetry

Persist separate download, extraction, remux, post-processing, and total durations together with accurate file, byte, archive, part, output, and phase failure results. Keep download item counts independent from extraction and remux outcomes, distinguish partial and cancelled packages, and expose additive failure counters and fixed categories in history and Discord notifications. Track archive operations across parallel work, aborts, restarts, nested multipart sets, and skipped split files while deriving generation outputs from privacy-safe signatures and merging overlapping extraction intervals.
This commit is contained in:
Sucukdeluxe
2026-08-24 20:59:44 +02:00
parent 7fe438ba5d
commit e8046f3fa3
16 changed files with 1139 additions and 150 deletions
+215 -51
View File
@@ -1977,6 +1977,8 @@ export class DownloadManager extends EventEmitter {
private finalizedPackageResults = new Map<string, PackageResult>();
private activeArchiveOperations = new Map<string, { packageId: string; operation: ArchiveOperationMetric }>();
private runContexts = new Map<string, RunLifecycleContext>();
private activeRunContextId: string | null = null;
@@ -4208,12 +4210,12 @@ export class DownloadManager extends EventEmitter {
return false;
}
private async countPackageOutputFiles(rootDir: string): Promise<number> {
private async collectPackageOutputSignatures(rootDir: string): Promise<Set<string>> {
if (!rootDir) {
return 0;
return new Set();
}
const stack = [rootDir];
let count = 0;
const signatures = new Set<string>();
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<void> {
if (pkg.outputBaselineSignatures !== undefined) {
return;
}
pkg.outputBaselineSignatures = [...await this.collectPackageOutputSignatures(pkg.extractDir)].sort();
}
private async refreshPackageOutputCount(pkg: PackageEntry): Promise<void> {
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<number> {
@@ -5836,6 +5858,7 @@ export class DownloadManager extends EventEmitter {
await this.moveCompanionFiles(sourcePath, targetPath, pkg);
} 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,
@@ -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();
}
@@ -8265,6 +8289,11 @@ export class DownloadManager extends EventEmitter {
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;
@@ -8287,6 +8316,10 @@ export class DownloadManager extends EventEmitter {
}
} while (this.hybridExtractRequeue.has(packageId));
} 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
@@ -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;
@@ -12792,35 +12838,85 @@ export class DownloadManager extends EventEmitter {
return false;
}
private async resolveArchivePartCount(archivePath: string): Promise<number> {
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<number> {
const completedForDeobfuscation = items.filter((item) => item.status === "completed");
await this.deobfuscateArchiveFiles(pkg, completedForDeobfuscation, signal);
@@ -12895,8 +13008,12 @@ export class DownloadManager extends EventEmitter {
.map((entry) => entry.name);
} catch { }
const archiveStems = new Set<string>();
const validatedArchivePartCounts = new Map<string, number>();
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);
@@ -13076,6 +13193,15 @@ export class DownloadManager extends EventEmitter {
}
}
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();
@@ -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", {
@@ -13526,9 +13646,13 @@ export class DownloadManager extends EventEmitter {
}
const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems);
const fullArchivePartCounts = new Map<string, number>();
const fullExtractItemIds = new Set<string>();
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);
}
@@ -13648,6 +13772,15 @@ export class DownloadManager extends EventEmitter {
}
}
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();
@@ -13655,12 +13788,6 @@ export class DownloadManager extends EventEmitter {
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,
@@ -13907,6 +14044,29 @@ export class DownloadManager extends EventEmitter {
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 <T>(operation: () => Promise<T>): Promise<T> => {
try {
return await operation();
} catch (error) {
if (!isExpectedAbort(error)) {
cleanupFailureRecorded = true;
this.recordPostProcessFailure(pkg, "cleanup", error);
}
throw error;
}
};
try {
throwIfAborted();
@@ -13924,6 +14084,12 @@ export class DownloadManager extends EventEmitter {
});
const nestedFailureCategories = new Map<string, string>();
const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[];
const nestedArchivePartCounts = new Map<string, number>();
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}`);
}
@@ -14052,17 +14221,12 @@ export class DownloadManager extends EventEmitter {
} 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") {
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 {
+17 -12
View File
@@ -68,6 +68,7 @@ export interface ExtractProgressUpdate {
total: number;
percent: number;
archiveName: string;
archivePath?: string;
archivePercent?: number;
elapsedMs?: number;
phase: "extracting" | "done" | "preparing";
@@ -76,6 +77,7 @@ export interface ExtractProgressUpdate {
passwordFound?: 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;
@@ -2982,6 +2985,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
total,
percent,
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);
@@ -3058,6 +3062,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const skippedAt = Date.now();
lastArchiveFinishedAt = skippedAt;
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=<redacted>`);
}
: 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)" : ""}`);
+15 -3
View File
@@ -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 }))
};
+7 -3
View File
@@ -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<NotificationEventType>([
"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<NotificationEventType>([
"package_partial",
"package_failed"
"package_failed",
"package_cancelled"
]);
const PACKAGE_FAILURE_PHASES = new Map<string, FailurePhase>([
["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";
}
+56 -10
View File
@@ -17,6 +17,7 @@ export type PackageFailureCategory =
| "Entpacken"
| "Remux"
| "Cleanup"
| "Nachbearbeitung"
| "Unbekannt";
const packageFailureCategories = new Map<string, PackageFailureCategory>([
@@ -29,6 +30,7 @@ const packageFailureCategories = new Map<string, PackageFailureCategory>([
["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))),
+16 -2
View File
@@ -46,8 +46,9 @@ const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["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) } : {})
};
@@ -60,6 +60,8 @@ const filterItems: Array<{ id: HistoryFilter; label: string }> = [
{ id: "week", label: "Letzte 7 Tage" },
{ id: "older", label: "Älter" },
{ 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({
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
<div><dt>Fehlerkategorie</dt><dd>{row.errorCategory || "—"}</dd></div>
<div><dt>Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung</dt><dd>{row.failureCountsLabel}</dd></div>
</>
) : (
<div><dt>Downloaddauer (Altbestand)</dt><dd>{row.durationLabel}</dd></div>
+24 -3
View File
@@ -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<HistoryEntry, "status"> & { 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
};
+12 -1
View File
@@ -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[];
}
+405 -1
View File
@@ -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[] = [];
@@ -12026,6 +12027,71 @@ describe("download manager", () => {
expect(fs.existsSync(originalExtractedPath)).toBe(false);
}, 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-"));
tempDirs.push(root);
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<void>;
refreshPackageOutputCount: (entry: PackageEntry) => Promise<void>;
};
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<number> };
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);
+62
View File
@@ -197,6 +197,36 @@ describe("extractor", () => {
]);
});
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-"));
tempDirs.push(root);
@@ -882,6 +912,38 @@ describe("extractor", () => {
expect(fs.existsSync(d002)).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", () => {
+36 -1
View File
@@ -127,6 +127,8 @@ describe("history model", () => {
week: ["week-edge", "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(<HistorySidebar actions={createActions()} model={model} />);
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)");
});
+72
View File
@@ -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"],
+73 -5
View File
@@ -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 () => {
+50 -5
View File
@@ -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"
}));
});
+23 -1
View File
@@ -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"
}));
});