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
+246 -82
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> {
@@ -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<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);
@@ -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<string>();
for (const archiveKey of readyArchives) {
const parts = collectArchiveCleanupTargets(archiveKey, dirFiles);
} 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);
@@ -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<string>();
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);
}
@@ -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 <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();
@@ -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<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}`);
}
@@ -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 {
+24 -19
View File
@@ -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=<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) } : {})
};