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:
+213
-49
@@ -1977,6 +1977,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private finalizedPackageResults = new Map<string, PackageResult>();
|
private finalizedPackageResults = new Map<string, PackageResult>();
|
||||||
|
|
||||||
|
private activeArchiveOperations = new Map<string, { packageId: string; operation: ArchiveOperationMetric }>();
|
||||||
|
|
||||||
private runContexts = new Map<string, RunLifecycleContext>();
|
private runContexts = new Map<string, RunLifecycleContext>();
|
||||||
|
|
||||||
private activeRunContextId: string | null = null;
|
private activeRunContextId: string | null = null;
|
||||||
@@ -4208,12 +4210,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countPackageOutputFiles(rootDir: string): Promise<number> {
|
private async collectPackageOutputSignatures(rootDir: string): Promise<Set<string>> {
|
||||||
if (!rootDir) {
|
if (!rootDir) {
|
||||||
return 0;
|
return new Set();
|
||||||
}
|
}
|
||||||
const stack = [rootDir];
|
const stack = [rootDir];
|
||||||
let count = 0;
|
const signatures = new Set<string>();
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
let entries: fs.Dirent[] = [];
|
let entries: fs.Dirent[] = [];
|
||||||
@@ -4230,11 +4232,31 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
stack.push(fullPath);
|
stack.push(fullPath);
|
||||||
} else if (entry.isFile() && !isArchiveLikePath(fullPath) && !isIgnorableEmptyDirFileName(entry.name)) {
|
} 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> {
|
private async removeEmptyDirectoryTree(rootDir: string): Promise<number> {
|
||||||
@@ -5836,6 +5858,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
await this.moveCompanionFiles(sourcePath, targetPath, pkg);
|
await this.moveCompanionFiles(sourcePath, targetPath, pkg);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
|
this.recordPostProcessFailure(pkg, "postprocess", error);
|
||||||
logger.warn(`MKV verschieben fehlgeschlagen: ${sourcePath} -> ${targetPath} (${compactErrorText(error)})`);
|
logger.warn(`MKV verschieben fehlgeschlagen: ${sourcePath} -> ${targetPath} (${compactErrorText(error)})`);
|
||||||
this.logPackageForPackage(pkg, "WARN", "MKV verschieben fehlgeschlagen", {
|
this.logPackageForPackage(pkg, "WARN", "MKV verschieben fehlgeschlagen", {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
@@ -8242,6 +8265,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
await this.acquirePostProcessSlot(packageId);
|
await this.acquirePostProcessSlot(packageId);
|
||||||
const startedPackage = this.session.packages[packageId];
|
const startedPackage = this.session.packages[packageId];
|
||||||
if (startedPackage) {
|
if (startedPackage) {
|
||||||
|
await this.capturePackageOutputBaseline(startedPackage);
|
||||||
startedPackage.postProcessStartedAt = startedPackage.postProcessStartedAt || nowMs();
|
startedPackage.postProcessStartedAt = startedPackage.postProcessStartedAt || nowMs();
|
||||||
startedPackage.updatedAt = nowMs();
|
startedPackage.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
@@ -8265,6 +8289,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await this.handlePackagePostProcessing(packageId, abortController.signal);
|
await this.handlePackagePostProcessing(packageId, abortController.signal);
|
||||||
} catch (error) {
|
} 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)}`);
|
logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`);
|
||||||
}
|
}
|
||||||
const roundMs = nowMs() - roundStart;
|
const roundMs = nowMs() - roundStart;
|
||||||
@@ -8287,6 +8316,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
} while (this.hybridExtractRequeue.has(packageId));
|
} while (this.hybridExtractRequeue.has(packageId));
|
||||||
} finally {
|
} finally {
|
||||||
|
const finalizingPackage = this.session.packages[packageId];
|
||||||
|
if (finalizingPackage) {
|
||||||
|
this.finalizeActiveArchiveOperations(finalizingPackage);
|
||||||
|
}
|
||||||
this.releasePostProcessSlot();
|
this.releasePostProcessSlot();
|
||||||
// Identity guard: only clear the map entries if they still point to THIS
|
// 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
|
// 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);
|
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||||
}
|
}
|
||||||
delete this.session.packages[packageId];
|
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.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
||||||
this.runCompletedPackages.delete(packageId);
|
this.runCompletedPackages.delete(packageId);
|
||||||
this.resetSessionTotalsIfQueueEmpty();
|
this.resetSessionTotalsIfQueueEmpty();
|
||||||
@@ -12011,7 +12049,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg.archiveOperations = [];
|
pkg.archiveOperations = [];
|
||||||
pkg.remuxOperations = [];
|
pkg.remuxOperations = [];
|
||||||
pkg.outputCount = 0;
|
pkg.outputCount = 0;
|
||||||
|
pkg.outputBaselineSignatures = undefined;
|
||||||
pkg.cleanupErrorCategory = "";
|
pkg.cleanupErrorCategory = "";
|
||||||
|
pkg.postProcessErrorCategory = "";
|
||||||
|
for (const [id, active] of this.activeArchiveOperations) {
|
||||||
|
if (active.packageId === packageId) {
|
||||||
|
this.activeArchiveOperations.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.pruneFinalizedPackageResults();
|
this.pruneFinalizedPackageResults();
|
||||||
return next;
|
return next;
|
||||||
@@ -12346,7 +12391,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
archiveOperations: pkg.archiveOperations,
|
archiveOperations: pkg.archiveOperations,
|
||||||
remuxOperations: pkg.remuxOperations,
|
remuxOperations: pkg.remuxOperations,
|
||||||
outputCount: pkg.outputCount,
|
outputCount: pkg.outputCount,
|
||||||
cleanupErrorCategory: pkg.cleanupErrorCategory
|
cleanupErrorCategory: pkg.cleanupErrorCategory,
|
||||||
|
postProcessErrorCategory: pkg.postProcessErrorCategory
|
||||||
});
|
});
|
||||||
this.finalizedPackageResults.set(key, result);
|
this.finalizedPackageResults.set(key, result);
|
||||||
pkg.status = result.status === "partial" ? "failed" : result.status;
|
pkg.status = result.status === "partial" ? "failed" : result.status;
|
||||||
@@ -12792,35 +12838,85 @@ export class DownloadManager extends EventEmitter {
|
|||||||
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(
|
private recordArchiveOperation(
|
||||||
pkg: PackageEntry,
|
pkg: PackageEntry,
|
||||||
progress: ExtractProgressUpdate,
|
progress: ExtractProgressUpdate,
|
||||||
items: DownloadItem[],
|
items: DownloadItem[],
|
||||||
errorCategory = ""
|
errorCategory = "",
|
||||||
|
validatedPartCount?: number
|
||||||
): void {
|
): void {
|
||||||
if (!progress.archiveName || progress.archiveDone !== true) {
|
if (!progress.archiveName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const completedAt = nowMs();
|
const observedAt = nowMs();
|
||||||
const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0));
|
const durationMs = Math.max(0, Math.floor(Number(progress.elapsedMs) || 0));
|
||||||
const itemIds = [...new Set(items.map((item) => item.id))];
|
const itemIds = [...new Set(items.map((item) => item.id))];
|
||||||
const itemProvenance = items
|
const itemProvenance = items
|
||||||
.map((item) => String(item.targetPath || item.id).replace(/\\/g, "/").toLocaleLowerCase("de-DE"))
|
.map((item) => String(item.targetPath || item.id).replace(/\\/g, "/").toLocaleLowerCase("de-DE"))
|
||||||
.sort();
|
.sort();
|
||||||
const archiveIdentity = itemProvenance.length > 0
|
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("|")
|
? itemProvenance.join("|")
|
||||||
: `${progress.archiveName.toLocaleLowerCase("de-DE")}:${Math.max(0, Math.floor(progress.current))}`;
|
: 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 = {
|
const operation: ArchiveOperationMetric = {
|
||||||
id: `${pkg.id}:${createHash("sha256").update(archiveIdentity).digest("hex").slice(0, 24)}`,
|
id,
|
||||||
name: progress.archiveName,
|
name: progress.archiveName,
|
||||||
itemIds,
|
itemIds,
|
||||||
partCount: itemIds.length,
|
partCount,
|
||||||
startedAt: Math.max(0, completedAt - durationMs),
|
startedAt: Math.max(0, observedAt - durationMs),
|
||||||
completedAt,
|
completedAt: observedAt,
|
||||||
durationMs,
|
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,
|
||||||
|
name: progress.archiveName,
|
||||||
|
itemIds,
|
||||||
|
partCount,
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
durationMs: durationMs > 0 ? durationMs : Math.max(0, completedAt - startedAt),
|
||||||
status: progress.archiveSuccess === false ? "failed" : "completed",
|
status: progress.archiveSuccess === false ? "failed" : "completed",
|
||||||
errorCategory: progress.archiveSuccess === false ? projectPackageFailureCategory("extract", errorCategory) : ""
|
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 operations = [...(pkg.archiveOperations || [])];
|
||||||
const existingIndex = operations.findIndex((entry) => entry.id === operation.id);
|
const existingIndex = operations.findIndex((entry) => entry.id === operation.id);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
@@ -12831,6 +12927,23 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg.archiveOperations = operations;
|
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> {
|
private async runHybridExtraction(packageId: string, pkg: PackageEntry, items: DownloadItem[], signal?: AbortSignal): Promise<number> {
|
||||||
const completedForDeobfuscation = items.filter((item) => item.status === "completed");
|
const completedForDeobfuscation = items.filter((item) => item.status === "completed");
|
||||||
await this.deobfuscateArchiveFiles(pkg, completedForDeobfuscation, signal);
|
await this.deobfuscateArchiveFiles(pkg, completedForDeobfuscation, signal);
|
||||||
@@ -12895,8 +13008,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
.map((entry) => entry.name);
|
.map((entry) => entry.name);
|
||||||
} catch { }
|
} catch { }
|
||||||
const archiveStems = new Set<string>();
|
const archiveStems = new Set<string>();
|
||||||
|
const validatedArchivePartCounts = new Map<string, number>();
|
||||||
for (const archiveKey of readyArchives) {
|
for (const archiveKey of readyArchives) {
|
||||||
const parts = collectArchiveCleanupTargets(archiveKey, dirFiles);
|
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) {
|
for (const part of parts) {
|
||||||
const partName = path.basename(part).toLowerCase();
|
const partName = path.basename(part).toLowerCase();
|
||||||
hybridFileNames.add(partName);
|
hybridFileNames.add(partName);
|
||||||
@@ -13076,6 +13193,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) {
|
if (archiveFinished) {
|
||||||
const doneAt = nowMs();
|
const doneAt = nowMs();
|
||||||
@@ -13087,12 +13213,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (archiveKey && progress.archiveSuccess !== false) {
|
if (archiveKey && progress.archiveSuccess !== false) {
|
||||||
this.clearHybridArchiveState(packageId, archiveKey);
|
this.clearHybridArchiveState(packageId, archiveKey);
|
||||||
}
|
}
|
||||||
this.recordArchiveOperation(
|
|
||||||
pkg,
|
|
||||||
progress,
|
|
||||||
archItems,
|
|
||||||
failedArchiveCategories.get(progress.archiveName.toLowerCase()) || ""
|
|
||||||
);
|
|
||||||
for (const entry of archItems) {
|
for (const entry of archItems) {
|
||||||
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
|
||||||
entry.fullStatus = doneLabel;
|
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}`);
|
logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`);
|
||||||
this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", {
|
this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", {
|
||||||
@@ -13526,9 +13646,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems);
|
const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems);
|
||||||
|
const fullArchivePartCounts = new Map<string, number>();
|
||||||
const fullExtractItemIds = new Set<string>();
|
const fullExtractItemIds = new Set<string>();
|
||||||
for (const archivePath of fullArchiveSet) {
|
for (const archivePath of fullArchiveSet) {
|
||||||
const archiveItems = resolveArchiveItems(path.basename(archivePath));
|
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) {
|
for (const entry of archiveItems) {
|
||||||
fullExtractItemIds.add(entry.id);
|
fullExtractItemIds.add(entry.id);
|
||||||
}
|
}
|
||||||
@@ -13648,6 +13772,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) {
|
if (archiveFinished) {
|
||||||
const doneAt = nowMs();
|
const doneAt = nowMs();
|
||||||
@@ -13655,12 +13788,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const doneLabel = progress.archiveSuccess === false
|
const doneLabel = progress.archiveSuccess === false
|
||||||
? "Entpacken - Error"
|
? "Entpacken - Error"
|
||||||
: formatExtractDone(doneAt - startedAt);
|
: formatExtractDone(doneAt - startedAt);
|
||||||
this.recordArchiveOperation(
|
|
||||||
pkg,
|
|
||||||
progress,
|
|
||||||
archiveItems,
|
|
||||||
fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || ""
|
|
||||||
);
|
|
||||||
for (const entry of archiveItems) {
|
for (const entry of archiveItems) {
|
||||||
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
|
||||||
entry.fullStatus = doneLabel;
|
entry.fullStatus = doneLabel;
|
||||||
@@ -13719,7 +13846,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
emitExtractStatus(overallLabel);
|
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 || ""}`);
|
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", {
|
this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", {
|
||||||
extracted: result.extracted,
|
extracted: result.extracted,
|
||||||
@@ -13872,6 +13999,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.trackPackagePostProcessResult(packageId);
|
this.trackPackagePostProcessResult(packageId);
|
||||||
const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount)
|
const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
this.finalizeActiveArchiveOperations(pkg);
|
||||||
const tasks = this.packageDeferredPostProcessTasks.get(packageId);
|
const tasks = this.packageDeferredPostProcessTasks.get(packageId);
|
||||||
tasks?.delete(task);
|
tasks?.delete(task);
|
||||||
if (tasks?.size === 0) {
|
if (tasks?.size === 0) {
|
||||||
@@ -13886,6 +14014,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return task;
|
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(
|
private async executeDeferredPostExtraction(
|
||||||
packageId: string,
|
packageId: string,
|
||||||
pkg: PackageEntry,
|
pkg: PackageEntry,
|
||||||
@@ -13907,6 +14044,29 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
return Boolean(item && item.status === "completed" && isExtractErrorLabel(item.fullStatus || ""));
|
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 {
|
try {
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
@@ -13924,6 +14084,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
const nestedFailureCategories = new Map<string, string>();
|
const nestedFailureCategories = new Map<string, string>();
|
||||||
const nestedItems = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[];
|
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({
|
const nestedResult = await extractPackageArchives({
|
||||||
packageDir: pkg.extractDir,
|
packageDir: pkg.extractDir,
|
||||||
targetDir: pkg.extractDir,
|
targetDir: pkg.extractDir,
|
||||||
@@ -13946,12 +14112,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg,
|
pkg,
|
||||||
progress,
|
progress,
|
||||||
resolveArchiveItemsFromList(progress.archiveName, nestedItems),
|
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();
|
throwIfAborted();
|
||||||
pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir));
|
await this.refreshPackageOutputCount(pkg);
|
||||||
extractedCount += nestedResult.extracted;
|
extractedCount += nestedResult.extracted;
|
||||||
logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`);
|
logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`);
|
||||||
this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", {
|
this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", {
|
||||||
@@ -13986,9 +14155,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase();
|
const sourceAndTargetEqual = path.resolve(pkg.outputDir).toLowerCase() === path.resolve(pkg.extractDir).toLowerCase();
|
||||||
if (!sourceAndTargetEqual) {
|
if (!sourceAndTargetEqual) {
|
||||||
const candidates = await findArchiveCandidates(pkg.outputDir);
|
const candidates = await runCleanup(() => findArchiveCandidates(pkg.outputDir));
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
const removed = await cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort });
|
const removed = await runCleanup(() => cleanupArchives(candidates, this.settings.cleanupMode, { shouldAbort }));
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
logger.info(`Deferred Archive-Cleanup: pkg=${pkg.name}, entfernt=${removed}`);
|
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) {
|
if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none" && !hasBlockingExtractError) {
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir, shouldAbort);
|
const removedArchives = await runCleanup(() => this.cleanupRemainingArchiveArtifacts(pkg.outputDir, shouldAbort));
|
||||||
if (removedArchives > 0) {
|
if (removedArchives > 0) {
|
||||||
logger.info(`Hybrid-Post-Cleanup entfernte Archive: pkg=${pkg.name}, entfernt=${removedArchives}`);
|
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) {
|
if (extractedCount > 0 || alreadyMarkedExtracted) {
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
if (this.settings.removeLinkFilesAfterExtract) {
|
if (this.settings.removeLinkFilesAfterExtract) {
|
||||||
const removedLinks = await removeDownloadLinkArtifacts(pkg.extractDir, { shouldAbort });
|
const removedLinks = await runCleanup(() => removeDownloadLinkArtifacts(pkg.extractDir, { shouldAbort }));
|
||||||
if (removedLinks > 0) {
|
if (removedLinks > 0) {
|
||||||
logger.info(`Deferred Link-Cleanup: pkg=${pkg.name}, entfernt=${removedLinks}`);
|
logger.info(`Deferred Link-Cleanup: pkg=${pkg.name}, entfernt=${removedLinks}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.settings.removeSamplesAfterExtract) {
|
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) {
|
if (removedSamples.files > 0 || removedSamples.dirs > 0) {
|
||||||
logger.info(`Deferred Sample-Cleanup: pkg=${pkg.name}, files=${removedSamples.files}, dirs=${removedSamples.dirs}`);
|
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) {
|
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) {
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
await clearExtractResumeState(pkg.outputDir, packageId);
|
await runCleanup(() => clearExtractResumeState(pkg.outputDir, packageId));
|
||||||
await clearExtractResumeState(pkg.outputDir);
|
await runCleanup(() => clearExtractResumeState(pkg.outputDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode === "delete") {
|
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode === "delete") {
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
if (!(await hasAnyFilesRecursive(pkg.outputDir))) {
|
if (!(await runCleanup(() => hasAnyFilesRecursive(pkg.outputDir)))) {
|
||||||
const removedDirs = await removeEmptyDirectoryTree(pkg.outputDir);
|
const removedDirs = await runCleanup(() => removeEmptyDirectoryTree(pkg.outputDir));
|
||||||
if (removedDirs > 0) {
|
if (removedDirs > 0) {
|
||||||
logger.info(`Deferred leere Download-Ordner entfernt: pkg=${pkg.name}, dirs=${removedDirs}`);
|
logger.info(`Deferred leere Download-Ordner entfernt: pkg=${pkg.name}, dirs=${removedDirs}`);
|
||||||
}
|
}
|
||||||
@@ -14052,17 +14221,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = compactErrorText(error);
|
const reason = compactErrorText(error);
|
||||||
if (reason.includes("aborted:deferred")
|
if (isExpectedAbort(error)) {
|
||||||
|| reason.includes("deferred_replaced")
|
|
||||||
|| reason.includes("package_removed")
|
|
||||||
|| reason === "reset"
|
|
||||||
|| reason === "cancel"
|
|
||||||
|| reason === "overwrite"
|
|
||||||
|| reason === "skip"
|
|
||||||
|| reason === "package_toggle") {
|
|
||||||
logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`);
|
logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`);
|
||||||
} else {
|
} else {
|
||||||
pkg.cleanupErrorCategory = projectPackageFailureCategory("cleanup", reason);
|
if (!cleanupFailureRecorded) {
|
||||||
|
this.recordPostProcessFailure(pkg, "postprocess", error);
|
||||||
|
}
|
||||||
logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`);
|
logger.warn(`Deferred Post-Extraction Fehler: pkg=${pkg.name}, reason=${reason}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+17
-12
@@ -68,6 +68,7 @@ export interface ExtractProgressUpdate {
|
|||||||
total: number;
|
total: number;
|
||||||
percent: number;
|
percent: number;
|
||||||
archiveName: string;
|
archiveName: string;
|
||||||
|
archivePath?: string;
|
||||||
archivePercent?: number;
|
archivePercent?: number;
|
||||||
elapsedMs?: number;
|
elapsedMs?: number;
|
||||||
phase: "extracting" | "done" | "preparing";
|
phase: "extracting" | "done" | "preparing";
|
||||||
@@ -76,6 +77,7 @@ export interface ExtractProgressUpdate {
|
|||||||
passwordFound?: boolean;
|
passwordFound?: boolean;
|
||||||
archiveDone?: boolean;
|
archiveDone?: boolean;
|
||||||
archiveSuccess?: boolean;
|
archiveSuccess?: boolean;
|
||||||
|
archiveSkipped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractArchiveFailureInfo {
|
export interface ExtractArchiveFailureInfo {
|
||||||
@@ -2961,7 +2963,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
archivePercent?: number,
|
archivePercent?: number,
|
||||||
elapsedMs?: number,
|
elapsedMs?: number,
|
||||||
pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean },
|
pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean },
|
||||||
archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean }
|
archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean; archiveSkipped?: boolean },
|
||||||
|
archivePath?: string
|
||||||
): void => {
|
): void => {
|
||||||
if (!options.onProgress) {
|
if (!options.onProgress) {
|
||||||
return;
|
return;
|
||||||
@@ -2982,6 +2985,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
total,
|
total,
|
||||||
percent,
|
percent,
|
||||||
archiveName,
|
archiveName,
|
||||||
|
...(archivePath ? { archivePath: path.resolve(archivePath) } : {}),
|
||||||
archivePercent: normalizedArchivePercent,
|
archivePercent: normalizedArchivePercent,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
phase,
|
phase,
|
||||||
@@ -2997,7 +3001,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
|
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
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 archivePercent = 0;
|
||||||
let reached99At: number | null = null;
|
let reached99At: number | null = null;
|
||||||
let archiveOutcome: "success" | "failed" | "skipped" = "failed";
|
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(() => {
|
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);
|
}, 1100);
|
||||||
const hybrid = Boolean(options.hybridMode);
|
const hybrid = Boolean(options.hybridMode);
|
||||||
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
||||||
@@ -3041,7 +3045,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
reached99At = Date.now();
|
reached99At = Date.now();
|
||||||
logger.info(`Extract-Trace 99%: archive=${archiveName}, elapsedMs=${reached99At - archiveStartedAt}`);
|
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);
|
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();
|
const skippedAt = Date.now();
|
||||||
lastArchiveFinishedAt = skippedAt;
|
lastArchiveFinishedAt = skippedAt;
|
||||||
logger.info(`Extract-Trace Archiv Übersprungen: archive=${archiveName}, ms=${skippedAt - archiveStartedAt}, reason=no-signature`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
logger.info(`Generische Split-Datei verifiziert (Signatur: ${sig}): ${archiveName}`);
|
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}`);
|
options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`);
|
||||||
const hasManyPasswords = archivePasswordCandidates.length > 1;
|
const hasManyPasswords = archivePasswordCandidates.length > 1;
|
||||||
if (hasManyPasswords) {
|
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
|
const onPwAttempt = hasManyPasswords
|
||||||
? (attempt: number, total: number) => {
|
? (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>`);
|
options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=<redacted>`);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -3135,9 +3140,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
lastArchiveFinishedAt = successAt;
|
lastArchiveFinishedAt = successAt;
|
||||||
archivePercent = 100;
|
archivePercent = 100;
|
||||||
if (hasManyPasswords) {
|
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 {
|
} 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) {
|
} catch (error) {
|
||||||
const errorText = String(error);
|
const errorText = String(error);
|
||||||
@@ -3168,7 +3173,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
const tailAfter99Ms = reached99At ? (failedAt - reached99At) : -1;
|
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}`);
|
logger.warn(`Extract-Trace Archiv Fehler: archive=${archiveName}, totalMs=${failedAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, category=${errorCategory}`);
|
||||||
lastArchiveFinishedAt = failedAt;
|
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)) {
|
if (isNoExtractorError(errorText)) {
|
||||||
noExtractorEncountered = true;
|
noExtractorEncountered = true;
|
||||||
}
|
}
|
||||||
@@ -3327,9 +3332,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
const nestedStartedAt = Date.now();
|
const nestedStartedAt = Date.now();
|
||||||
let nestedPercent = 0;
|
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(() => {
|
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);
|
}, 1100);
|
||||||
const hybrid = Boolean(options.hybridMode);
|
const hybrid = Boolean(options.hybridMode);
|
||||||
logger.info(`Nested-Entpacke: ${nestedName} -> ${options.targetDir}${hybrid ? " (hybrid)" : ""}`);
|
logger.info(`Nested-Entpacke: ${nestedName} -> ${options.targetDir}${hybrid ? " (hybrid)" : ""}`);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface RunResult {
|
|||||||
downloadFailures: number;
|
downloadFailures: number;
|
||||||
offlineFailures: number;
|
offlineFailures: number;
|
||||||
cleanupFailures: number;
|
cleanupFailures: number;
|
||||||
|
postProcessFailures: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunResultInput {
|
export interface RunResultInput {
|
||||||
@@ -149,6 +150,7 @@ function failurePhaseLabel(result: PackageResult): string {
|
|||||||
if (result.failurePhase === "extract") return "Entpacken";
|
if (result.failurePhase === "extract") return "Entpacken";
|
||||||
if (result.failurePhase === "remux") return "Remux";
|
if (result.failurePhase === "remux") return "Remux";
|
||||||
if (result.failurePhase === "cleanup") return "Aufräumen";
|
if (result.failurePhase === "cleanup") return "Aufräumen";
|
||||||
|
if (result.failurePhase === "postprocess") return "Nachbearbeitung";
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +179,7 @@ function event(
|
|||||||
function packageEventType(status: PackageResultStatus): NotificationEventType {
|
function packageEventType(status: PackageResultStatus): NotificationEventType {
|
||||||
if (status === "completed") return "package_completed";
|
if (status === "completed") return "package_completed";
|
||||||
if (status === "partial") return "package_partial";
|
if (status === "partial") return "package_partial";
|
||||||
|
if (status === "cancelled") return "package_cancelled";
|
||||||
return "package_failed";
|
return "package_failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +202,7 @@ export function buildPackageNotificationEvent(
|
|||||||
{ name: "Ergebnis", value: statusLabel(result.status), inline: true },
|
{ name: "Ergebnis", value: statusLabel(result.status), inline: true },
|
||||||
{ name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false },
|
{ 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: "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: "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 }
|
{ 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),
|
remuxFailures: sum((result) => result.remuxFailures),
|
||||||
downloadFailures: sum((result) => result.downloadFailures),
|
downloadFailures: sum((result) => result.downloadFailures),
|
||||||
offlineFailures: sum((result) => result.offlineFailures),
|
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: "Remuxfehler", value: String(result.remuxFailures), inline: true },
|
||||||
{ name: "Downloadfehler", value: String(result.downloadFailures), inline: true },
|
{ name: "Downloadfehler", value: String(result.downloadFailures), inline: true },
|
||||||
{ name: "Offline", value: String(result.offlineFailures), 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,
|
partCount: result.partCount,
|
||||||
outputCount: result.outputCount,
|
outputCount: result.outputCount,
|
||||||
failurePhase: result.failurePhase,
|
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] })),
|
archiveOperations: result.archiveOperations.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] })),
|
||||||
remuxOperations: result.remuxOperations.map((operation) => ({ ...operation }))
|
remuxOperations: result.remuxOperations.map((operation) => ({ ...operation }))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type NotificationEventType =
|
|||||||
| "package_completed"
|
| "package_completed"
|
||||||
| "package_partial"
|
| "package_partial"
|
||||||
| "package_failed"
|
| "package_failed"
|
||||||
|
| "package_cancelled"
|
||||||
| "run_completed"
|
| "run_completed"
|
||||||
| "run_stopped"
|
| "run_stopped"
|
||||||
| "remaining_threshold_crossed"
|
| "remaining_threshold_crossed"
|
||||||
@@ -60,6 +61,7 @@ const EVENT_TYPES = new Set<NotificationEventType>([
|
|||||||
"package_completed",
|
"package_completed",
|
||||||
"package_partial",
|
"package_partial",
|
||||||
"package_failed",
|
"package_failed",
|
||||||
|
"package_cancelled",
|
||||||
"run_completed",
|
"run_completed",
|
||||||
"run_stopped",
|
"run_stopped",
|
||||||
"remaining_threshold_crossed",
|
"remaining_threshold_crossed",
|
||||||
@@ -71,13 +73,15 @@ const MAX_RETRY_DELAY_MS = 10 * 60 * 1000;
|
|||||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000;
|
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000;
|
||||||
const PACKAGE_FAILURE_EVENT_TYPES = new Set<NotificationEventType>([
|
const PACKAGE_FAILURE_EVENT_TYPES = new Set<NotificationEventType>([
|
||||||
"package_partial",
|
"package_partial",
|
||||||
"package_failed"
|
"package_failed",
|
||||||
|
"package_cancelled"
|
||||||
]);
|
]);
|
||||||
const PACKAGE_FAILURE_PHASES = new Map<string, FailurePhase>([
|
const PACKAGE_FAILURE_PHASES = new Map<string, FailurePhase>([
|
||||||
["Download", "download"],
|
["Download", "download"],
|
||||||
["Entpacken", "extract"],
|
["Entpacken", "extract"],
|
||||||
["Remux", "remux"],
|
["Remux", "remux"],
|
||||||
["Aufräumen", "cleanup"]
|
["Aufräumen", "cleanup"],
|
||||||
|
["Nachbearbeitung", "postprocess"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function finiteInteger(value: unknown, fallback = 0): number {
|
function finiteInteger(value: unknown, fallback = 0): number {
|
||||||
@@ -86,7 +90,7 @@ function finiteInteger(value: unknown, fallback = 0): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePackageFailureFieldValue(value: string): string {
|
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) {
|
if (!match) {
|
||||||
return "Unbekannt";
|
return "Unbekannt";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type PackageFailureCategory =
|
|||||||
| "Entpacken"
|
| "Entpacken"
|
||||||
| "Remux"
|
| "Remux"
|
||||||
| "Cleanup"
|
| "Cleanup"
|
||||||
|
| "Nachbearbeitung"
|
||||||
| "Unbekannt";
|
| "Unbekannt";
|
||||||
|
|
||||||
const packageFailureCategories = new Map<string, PackageFailureCategory>([
|
const packageFailureCategories = new Map<string, PackageFailureCategory>([
|
||||||
@@ -29,6 +30,7 @@ const packageFailureCategories = new Map<string, PackageFailureCategory>([
|
|||||||
["entpacken", "Entpacken"],
|
["entpacken", "Entpacken"],
|
||||||
["remux", "Remux"],
|
["remux", "Remux"],
|
||||||
["cleanup", "Cleanup"],
|
["cleanup", "Cleanup"],
|
||||||
|
["nachbearbeitung", "Nachbearbeitung"],
|
||||||
["unbekannt", "Unbekannt"]
|
["unbekannt", "Unbekannt"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -51,6 +53,34 @@ export function sumOperationDurationSeconds(operations: readonly { durationMs: n
|
|||||||
return durationMsToSeconds(operations.reduce((total, operation) => total + finiteNonNegative(operation.durationMs), 0));
|
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 {
|
export function projectPackageFailureCategory(failurePhase: FailurePhase, detail: unknown): PackageFailureCategory {
|
||||||
const normalized = String(detail ?? "").trim().slice(0, 2048).toLowerCase();
|
const normalized = String(detail ?? "").trim().slice(0, 2048).toLowerCase();
|
||||||
const knownCategory = packageFailureCategories.get(normalized);
|
const knownCategory = packageFailureCategories.get(normalized);
|
||||||
@@ -76,17 +106,25 @@ export function projectPackageFailureCategory(failurePhase: FailurePhase, detail
|
|||||||
if (failurePhase === "extract") return "Entpacken";
|
if (failurePhase === "extract") return "Entpacken";
|
||||||
if (failurePhase === "remux") return "Remux";
|
if (failurePhase === "remux") return "Remux";
|
||||||
if (failurePhase === "cleanup") return "Cleanup";
|
if (failurePhase === "cleanup") return "Cleanup";
|
||||||
|
if (failurePhase === "postprocess") return "Nachbearbeitung";
|
||||||
return "Unbekannt";
|
return "Unbekannt";
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyStatus(successfulFiles: number, failedFiles: number, cancelledFiles: number, packageCancelled: boolean): PackageResultStatus {
|
function classifyStatus(
|
||||||
if (successfulFiles > 0 && (failedFiles > 0 || cancelledFiles > 0 || packageCancelled)) {
|
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";
|
return "partial";
|
||||||
}
|
}
|
||||||
if (failedFiles > 0) {
|
if (failedFiles > 0 || postProcessFailures > 0) {
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
if (cancelledFiles > 0 || packageCancelled) {
|
if (cancelledFiles > 0 || postProcessCancellations > 0 || packageCancelled) {
|
||||||
return "cancelled";
|
return "cancelled";
|
||||||
}
|
}
|
||||||
return "completed";
|
return "completed";
|
||||||
@@ -94,6 +132,7 @@ function classifyStatus(successfulFiles: number, failedFiles: number, cancelledF
|
|||||||
|
|
||||||
function getFailure(
|
function getFailure(
|
||||||
cleanupErrorCategory: string,
|
cleanupErrorCategory: string,
|
||||||
|
postProcessErrorCategory: string,
|
||||||
remuxOperations: readonly RemuxOperationMetric[],
|
remuxOperations: readonly RemuxOperationMetric[],
|
||||||
remuxFallbackFailures: number,
|
remuxFallbackFailures: number,
|
||||||
archiveOperations: readonly ArchiveOperationMetric[],
|
archiveOperations: readonly ArchiveOperationMetric[],
|
||||||
@@ -102,6 +141,9 @@ function getFailure(
|
|||||||
if (cleanupErrorCategory) {
|
if (cleanupErrorCategory) {
|
||||||
return { failurePhase: "cleanup", errorCategory: projectPackageFailureCategory("cleanup", 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");
|
const failedRemux = remuxOperations.find((operation) => operation.status === "failed");
|
||||||
if (failedRemux || remuxFallbackFailures > 0) {
|
if (failedRemux || remuxFallbackFailures > 0) {
|
||||||
return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) };
|
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 audioStripFailures = Math.max(0, Math.floor(finiteNonNegative(packageEntry.audioStripSummary?.failed)));
|
||||||
const remuxFailures = Math.max(failedRemuxOperations, audioStripFailures);
|
const remuxFailures = Math.max(failedRemuxOperations, audioStripFailures);
|
||||||
const cleanupErrorCategory = String(telemetry.cleanupErrorCategory ?? packageEntry.cleanupErrorCategory ?? "").trim();
|
const cleanupErrorCategory = String(telemetry.cleanupErrorCategory ?? packageEntry.cleanupErrorCategory ?? "").trim();
|
||||||
|
const postProcessErrorCategory = String(telemetry.postProcessErrorCategory ?? packageEntry.postProcessErrorCategory ?? "").trim();
|
||||||
const downloadFailureCategories = failedDownloads.map((item) =>
|
const downloadFailureCategories = failedDownloads.map((item) =>
|
||||||
projectPackageFailureCategory("download", item.lastError || item.fullStatus)
|
projectPackageFailureCategory("download", item.lastError || item.fullStatus)
|
||||||
);
|
);
|
||||||
const offlineFailures = downloadFailureCategories.filter((category) => category === "Offline").length;
|
const offlineFailures = downloadFailureCategories.filter((category) => category === "Offline").length;
|
||||||
const cleanupFailures = cleanupErrorCategory ? 1 : 0;
|
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 postProcessCancellations = cancelledArchives + cancelledRemuxOperations;
|
||||||
const failedFiles = failedDownloads.length + postProcessFailures;
|
const failedFiles = failedDownloads.length;
|
||||||
const cancelledFiles = cancelledDownloads + postProcessCancellations;
|
const cancelledFiles = cancelledDownloads;
|
||||||
const successfulFiles = Math.max(0, completedDownloads - postProcessFailures - postProcessCancellations);
|
const successfulFiles = completedDownloads;
|
||||||
const startedAt = finiteNonNegative(packageEntry.downloadStartedAt);
|
const startedAt = finiteNonNegative(packageEntry.downloadStartedAt);
|
||||||
const downloadEndedAt = finiteNonNegative(packageEntry.downloadEndedAt) || finiteNonNegative(packageEntry.downloadCompletedAt);
|
const downloadEndedAt = finiteNonNegative(packageEntry.downloadEndedAt) || finiteNonNegative(packageEntry.downloadCompletedAt);
|
||||||
const postProcessStartedAt = finiteNonNegative(packageEntry.postProcessStartedAt);
|
const postProcessStartedAt = finiteNonNegative(packageEntry.postProcessStartedAt);
|
||||||
@@ -154,12 +198,13 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul
|
|||||||
const totalBytes = finiteNonNegative(packageEntry.cleanedTotalBytes)
|
const totalBytes = finiteNonNegative(packageEntry.cleanedTotalBytes)
|
||||||
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.totalBytes ?? item.downloadedBytes), 0);
|
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.totalBytes ?? item.downloadedBytes), 0);
|
||||||
const downloadDurationSeconds = durationSecondsBetween(startedAt, downloadEndedAt);
|
const downloadDurationSeconds = durationSecondsBetween(startedAt, downloadEndedAt);
|
||||||
const extractionDurationSeconds = sumOperationDurationSeconds(archiveOperations);
|
const extractionDurationSeconds = unionOperationDurationSeconds(archiveOperations);
|
||||||
const remuxDurationSeconds = sumOperationDurationSeconds(remuxOperations);
|
const remuxDurationSeconds = sumOperationDurationSeconds(remuxOperations);
|
||||||
const postProcessDurationSeconds = durationSecondsBetween(postProcessStartedAt, postProcessCompletedAt);
|
const postProcessDurationSeconds = durationSecondsBetween(postProcessStartedAt, postProcessCompletedAt);
|
||||||
const totalDurationSeconds = durationSecondsBetween(startedAt, completedAt);
|
const totalDurationSeconds = durationSecondsBetween(startedAt, completedAt);
|
||||||
const failure = getFailure(
|
const failure = getFailure(
|
||||||
cleanupErrorCategory,
|
cleanupErrorCategory,
|
||||||
|
postProcessErrorCategory,
|
||||||
remuxOperations,
|
remuxOperations,
|
||||||
audioStripFailures,
|
audioStripFailures,
|
||||||
archiveOperations,
|
archiveOperations,
|
||||||
@@ -169,7 +214,7 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul
|
|||||||
return {
|
return {
|
||||||
packageId: packageEntry.id,
|
packageId: packageEntry.id,
|
||||||
name: packageEntry.name,
|
name: packageEntry.name,
|
||||||
status: classifyStatus(successfulFiles, failedFiles, cancelledFiles, packageEntry.cancelled),
|
status: classifyStatus(successfulFiles, failedFiles, cancelledFiles, operationFailures, postProcessCancellations, packageEntry.cancelled),
|
||||||
startedAt,
|
startedAt,
|
||||||
downloadEndedAt,
|
downloadEndedAt,
|
||||||
postProcessStartedAt,
|
postProcessStartedAt,
|
||||||
@@ -190,6 +235,7 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul
|
|||||||
extractionFailures: failedArchives,
|
extractionFailures: failedArchives,
|
||||||
remuxFailures,
|
remuxFailures,
|
||||||
cleanupFailures,
|
cleanupFailures,
|
||||||
|
postProcessFailures,
|
||||||
archiveCount: archiveOperations.length,
|
archiveCount: archiveOperations.length,
|
||||||
partCount: archiveOperations.reduce((total, operation) => total + Math.max(0, Math.floor(finiteNonNegative(operation.partCount))), 0),
|
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))),
|
outputCount: Math.max(0, Math.floor(finiteNonNegative(telemetry.outputCount ?? packageEntry.outputCount))),
|
||||||
|
|||||||
+16
-2
@@ -46,8 +46,9 @@ const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid"
|
|||||||
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
||||||
const VALID_OPERATION_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
const VALID_OPERATION_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
||||||
const VALID_HISTORY_STATUSES = new Set(["completed", "partial", "failed", "cancelled", "deleted"]);
|
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_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
||||||
|
const SAFE_OUTPUT_SIGNATURE_RE = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
function asText(value: unknown): string {
|
function asText(value: unknown): string {
|
||||||
return String(value ?? "").trim();
|
return String(value ?? "").trim();
|
||||||
@@ -1019,7 +1020,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
archiveOperations: normalizeArchiveOperations(pkg.archiveOperations),
|
archiveOperations: normalizeArchiveOperations(pkg.archiveOperations),
|
||||||
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
|
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
|
||||||
outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000),
|
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),
|
cleanupErrorCategory: normalizeFailureCategory("cleanup", pkg.cleanupErrorCategory),
|
||||||
|
postProcessErrorCategory: normalizeFailureCategory("postprocess", pkg.postProcessErrorCategory),
|
||||||
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
|
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||||
updatedAt: clampNumber(pkg.updatedAt, 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),
|
cancelledFiles: optionalClampedNumber(entry, "cancelledFiles", 1_000_000),
|
||||||
archiveCount: optionalClampedNumber(entry, "archiveCount", 100_000),
|
archiveCount: optionalClampedNumber(entry, "archiveCount", 100_000),
|
||||||
partCount: optionalClampedNumber(entry, "partCount", 1_000_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 {
|
return {
|
||||||
@@ -1626,6 +1637,9 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
|
|||||||
...(failurePhaseRaw === null || VALID_FAILURE_PHASES.has(failurePhaseRaw)
|
...(failurePhaseRaw === null || VALID_FAILURE_PHASES.has(failurePhaseRaw)
|
||||||
? { failurePhase: failurePhaseRaw as FailurePhase }
|
? { 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.archiveOperations) ? { archiveOperations: normalizeArchiveOperations(entry.archiveOperations) } : {}),
|
||||||
...(Array.isArray(entry.remuxOperations) ? { remuxOperations: normalizeRemuxOperations(entry.remuxOperations) } : {})
|
...(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: "week", label: "Letzte 7 Tage" },
|
||||||
{ id: "older", label: "Älter" },
|
{ id: "older", label: "Älter" },
|
||||||
{ id: "completed", label: "Fertig" },
|
{ id: "completed", label: "Fertig" },
|
||||||
|
{ id: "partial", label: "Teilweise" },
|
||||||
|
{ id: "cancelled", label: "Abgebrochen" },
|
||||||
{ id: "deleted", label: "Gelöscht" },
|
{ id: "deleted", label: "Gelöscht" },
|
||||||
{ id: "failed", label: "Fehlgeschlagen" }
|
{ 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>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>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>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>
|
<div><dt>Downloaddauer (Altbestand)</dt><dd>{row.durationLabel}</dd></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { DebridProvider, HistoryEntry } from "../../../shared/types";
|
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 HistoryViewStatus = HistoryEntry["status"] | "failed";
|
||||||
export type HistoryViewEntry = Omit<HistoryEntry, "status"> & { status: HistoryViewStatus };
|
export type HistoryViewEntry = Omit<HistoryEntry, "status"> & { status: HistoryViewStatus };
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ export interface HistoryRow extends HistoryViewEntry {
|
|||||||
postProcessDurationLabel: string;
|
postProcessDurationLabel: string;
|
||||||
totalDurationLabel: string;
|
totalDurationLabel: string;
|
||||||
failurePhaseLabel: string;
|
failurePhaseLabel: string;
|
||||||
|
failureCountsLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryFilterCounts {
|
export interface HistoryFilterCounts {
|
||||||
@@ -31,6 +32,8 @@ export interface HistoryFilterCounts {
|
|||||||
week: number;
|
week: number;
|
||||||
older: number;
|
older: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
|
partial: number;
|
||||||
|
cancelled: number;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
@@ -178,9 +181,24 @@ function failurePhaseLabel(entry: HistoryViewEntry): string {
|
|||||||
if (entry.failurePhase === "extract") return "Entpacken";
|
if (entry.failurePhase === "extract") return "Entpacken";
|
||||||
if (entry.failurePhase === "remux") return "Remux";
|
if (entry.failurePhase === "remux") return "Remux";
|
||||||
if (entry.failurePhase === "cleanup") return "Aufräumen";
|
if (entry.failurePhase === "cleanup") return "Aufräumen";
|
||||||
|
if (entry.failurePhase === "postprocess") return "Nachbearbeitung";
|
||||||
return "—";
|
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 {
|
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
|
||||||
const totalItems = rows.length;
|
const totalItems = rows.length;
|
||||||
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
|
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 {
|
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;
|
return entry.status === filter;
|
||||||
}
|
}
|
||||||
if (filter === "all") {
|
if (filter === "all") {
|
||||||
@@ -296,7 +314,8 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
|
|||||||
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
|
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
|
||||||
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
|
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
|
||||||
totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 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,
|
week: entries.filter((entry) => matchesTemporalFilter(entry, "week", now)).length,
|
||||||
older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length,
|
older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length,
|
||||||
completed: entries.filter((entry) => entry.status === "completed").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,
|
deleted: entries.filter((entry) => entry.status === "deleted").length,
|
||||||
failed: entries.filter((entry) => entry.status === "failed").length
|
failed: entries.filter((entry) => entry.status === "failed").length
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-1
@@ -503,7 +503,7 @@ export interface AudioStripSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PackageResultStatus = "completed" | "partial" | "failed" | "cancelled";
|
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 {
|
export interface ArchiveOperationMetric {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -534,6 +534,7 @@ export interface PackageTelemetry {
|
|||||||
remuxOperations?: RemuxOperationMetric[];
|
remuxOperations?: RemuxOperationMetric[];
|
||||||
outputCount?: number;
|
outputCount?: number;
|
||||||
cleanupErrorCategory?: string;
|
cleanupErrorCategory?: string;
|
||||||
|
postProcessErrorCategory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageResult {
|
export interface PackageResult {
|
||||||
@@ -560,6 +561,7 @@ export interface PackageResult {
|
|||||||
extractionFailures: number;
|
extractionFailures: number;
|
||||||
remuxFailures: number;
|
remuxFailures: number;
|
||||||
cleanupFailures: number;
|
cleanupFailures: number;
|
||||||
|
postProcessFailures: number;
|
||||||
archiveCount: number;
|
archiveCount: number;
|
||||||
partCount: number;
|
partCount: number;
|
||||||
outputCount: number;
|
outputCount: number;
|
||||||
@@ -597,7 +599,9 @@ export interface PackageEntry {
|
|||||||
archiveOperations?: ArchiveOperationMetric[];
|
archiveOperations?: ArchiveOperationMetric[];
|
||||||
remuxOperations?: RemuxOperationMetric[];
|
remuxOperations?: RemuxOperationMetric[];
|
||||||
outputCount?: number;
|
outputCount?: number;
|
||||||
|
outputBaselineSignatures?: string[];
|
||||||
cleanupErrorCategory?: string;
|
cleanupErrorCategory?: string;
|
||||||
|
postProcessErrorCategory?: string;
|
||||||
resultGeneration?: number;
|
resultGeneration?: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -945,6 +949,13 @@ export interface HistoryEntry {
|
|||||||
partCount?: number;
|
partCount?: number;
|
||||||
outputCount?: number;
|
outputCount?: number;
|
||||||
failurePhase?: FailurePhase;
|
failurePhase?: FailurePhase;
|
||||||
|
errorCategory?: string;
|
||||||
|
downloadFailures?: number;
|
||||||
|
offlineFailures?: number;
|
||||||
|
extractionFailures?: number;
|
||||||
|
remuxFailures?: number;
|
||||||
|
cleanupFailures?: number;
|
||||||
|
postProcessFailures?: number;
|
||||||
archiveOperations?: ArchiveOperationMetric[];
|
archiveOperations?: ArchiveOperationMetric[];
|
||||||
remuxOperations?: RemuxOperationMetric[];
|
remuxOperations?: RemuxOperationMetric[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||||
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-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 { loadStatisticsLedger } from "../src/main/statistics-ledger";
|
||||||
import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
|
import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
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 { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
import { resetVideoToolingCache } from "../src/main/video-processor";
|
import { resetVideoToolingCache } from "../src/main/video-processor";
|
||||||
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
|
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";
|
import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@@ -12026,6 +12027,71 @@ describe("download manager", () => {
|
|||||||
expect(fs.existsSync(originalExtractedPath)).toBe(false);
|
expect(fs.existsSync(originalExtractedPath)).toBe(false);
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
|
it("records a real library move failure as post-processing telemetry", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-library-move-failure-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const outputDir = path.join(root, "downloads", "package");
|
||||||
|
const libraryDir = path.join(root, "library");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, "episode.mkv"), "video", "utf8");
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "library-move-failure";
|
||||||
|
const itemId = "library-move-failure-item";
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "Library move failure",
|
||||||
|
outputDir,
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
downloadStartedAt: 1_000,
|
||||||
|
downloadEndedAt: 2_000,
|
||||||
|
postProcessStartedAt: 2_000,
|
||||||
|
postProcessCompletedAt: 3_000,
|
||||||
|
terminalAt: 3_000,
|
||||||
|
createdAt: 1_000,
|
||||||
|
updatedAt: 3_000
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://example.test/episode.mkv",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 5,
|
||||||
|
totalBytes: 5,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "episode.mkv",
|
||||||
|
targetPath: path.join(outputDir, "episode.mkv"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Fertig",
|
||||||
|
createdAt: 1_000,
|
||||||
|
updatedAt: 2_000
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager({
|
||||||
|
...defaultSettings(),
|
||||||
|
autoExtract: false,
|
||||||
|
collectMkvToLibrary: true,
|
||||||
|
mkvLibraryDir: libraryDir
|
||||||
|
}, session, createStoragePaths(path.join(root, "state")));
|
||||||
|
const state = manager as any;
|
||||||
|
vi.spyOn(state, "moveFileWithExdevFallback").mockRejectedValue(new Error("move denied"));
|
||||||
|
const pkg = state.session.packages[packageId] as PackageEntry;
|
||||||
|
|
||||||
|
await state.collectMkvFilesToLibrary(packageId, pkg);
|
||||||
|
const result = finalizePackageResult({ package: pkg, items: [state.session.items[itemId]] });
|
||||||
|
|
||||||
|
expect(pkg.postProcessErrorCategory).toBe("Nachbearbeitung");
|
||||||
|
expect(result).toMatchObject({ status: "partial", failurePhase: "postprocess", postProcessFailures: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
it("moves extracted AVI files into a flat library folder per completed package", async () => {
|
it("moves extracted AVI files into a flat library folder per completed package", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -14592,6 +14658,344 @@ describe("package lifecycle telemetry boundaries", () => {
|
|||||||
expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]);
|
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", () => {
|
it("projects archive failure details before storing operation telemetry", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-failure-category-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-failure-category-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@@ -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 () => {
|
it("deletes split zip companion parts when cleanup is enabled", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -882,6 +912,38 @@ describe("extractor", () => {
|
|||||||
expect(fs.existsSync(d002)).toBe(true);
|
expect(fs.existsSync(d002)).toBe(true);
|
||||||
expect(fs.existsSync(d003)).toBe(true);
|
expect(fs.existsSync(d003)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits terminal skipped progress for a generic split without an archive signature", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-skip-progress-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
const archivePath = path.join(packageDir, "data.001");
|
||||||
|
fs.writeFileSync(archivePath, "plain data without archive signature", "utf8");
|
||||||
|
const progress: Array<{ archiveDone?: boolean; archiveSkipped?: boolean; archivePath?: string }> = [];
|
||||||
|
|
||||||
|
await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
onlyArchives: new Set([process.platform === "win32" ? path.resolve(archivePath).toLowerCase() : path.resolve(archivePath)]),
|
||||||
|
onProgress: (update) => {
|
||||||
|
if (update.archiveName === "data.001") {
|
||||||
|
progress.push(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(progress.at(-1)).toMatchObject({
|
||||||
|
archiveDone: true,
|
||||||
|
archiveSkipped: true,
|
||||||
|
archivePath: path.resolve(archivePath)
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("detectArchiveSignature", () => {
|
describe("detectArchiveSignature", () => {
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ describe("history model", () => {
|
|||||||
week: ["week-edge", "week"],
|
week: ["week-edge", "week"],
|
||||||
older: ["older"],
|
older: ["older"],
|
||||||
completed: ["today", "week-edge"],
|
completed: ["today", "week-edge"],
|
||||||
|
partial: [],
|
||||||
|
cancelled: [],
|
||||||
deleted: ["week"],
|
deleted: ["week"],
|
||||||
failed: ["older"]
|
failed: ["older"]
|
||||||
};
|
};
|
||||||
@@ -145,6 +147,28 @@ describe("history model", () => {
|
|||||||
expect(rows.map((row) => row.statusLabel)).toEqual(["Teilweise", "Abgebrochen"]);
|
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", () => {
|
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 springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime();
|
||||||
const springBoundary = new Date(2026, 2, 24, 0, 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} />);
|
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
|
||||||
|
|
||||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
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);
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -632,6 +656,13 @@ describe("HistoryView", () => {
|
|||||||
partCount: 16,
|
partCount: 16,
|
||||||
outputCount: 10,
|
outputCount: 10,
|
||||||
failurePhase: "remux",
|
failurePhase: "remux",
|
||||||
|
errorCategory: "Berechtigung",
|
||||||
|
downloadFailures: 1,
|
||||||
|
offlineFailures: 1,
|
||||||
|
extractionFailures: 2,
|
||||||
|
remuxFailures: 3,
|
||||||
|
cleanupFailures: 4,
|
||||||
|
postProcessFailures: 5,
|
||||||
archiveOperations: [{
|
archiveOperations: [{
|
||||||
id: "archive-1",
|
id: "archive-1",
|
||||||
name: "show.part01.rar",
|
name: "show.part01.rar",
|
||||||
@@ -673,6 +704,8 @@ describe("HistoryView", () => {
|
|||||||
"Erfolgreich / Fehlgeschlagen / Abgebrochen",
|
"Erfolgreich / Fehlgeschlagen / Abgebrochen",
|
||||||
"Archive / Parts / Ausgaben",
|
"Archive / Parts / Ausgaben",
|
||||||
"Fehlerphase",
|
"Fehlerphase",
|
||||||
|
"Fehlerkategorie",
|
||||||
|
"Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung",
|
||||||
"Archivvorgänge",
|
"Archivvorgänge",
|
||||||
"Remuxvorgänge"
|
"Remuxvorgänge"
|
||||||
]) {
|
]) {
|
||||||
@@ -682,6 +715,8 @@ describe("HistoryView", () => {
|
|||||||
expect(html).toContain("16 Parts");
|
expect(html).toContain("16 Parts");
|
||||||
expect(html).toContain("episode.mkv");
|
expect(html).toContain("episode.mkv");
|
||||||
expect(html).toContain("ffmpeg");
|
expect(html).toContain("ffmpeg");
|
||||||
|
expect(html).toContain("Berechtigung");
|
||||||
|
expect(html).toContain("1 / 1 / 2 / 3 / 4 / 5");
|
||||||
expect(html).not.toContain("Downloaddauer (Altbestand)");
|
expect(html).not.toContain("Downloaddauer (Altbestand)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -586,6 +586,7 @@ describe("NotificationOutbox", () => {
|
|||||||
extractionFailures: 0,
|
extractionFailures: 0,
|
||||||
remuxFailures: 0,
|
remuxFailures: 0,
|
||||||
cleanupFailures: 0,
|
cleanupFailures: 0,
|
||||||
|
postProcessFailures: 0,
|
||||||
archiveCount: 0,
|
archiveCount: 0,
|
||||||
partCount: 0,
|
partCount: 0,
|
||||||
outputCount: 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([
|
it.each([
|
||||||
["fullStatus fallback", "fullStatus", "Download · Download"],
|
["fullStatus fallback", "fullStatus", "Download · Download"],
|
||||||
["remux operation", "remux", "Remux · Remux"],
|
["remux operation", "remux", "Remux · Remux"],
|
||||||
|
|||||||
@@ -208,9 +208,17 @@ describe("authoritative package completion", () => {
|
|||||||
state.tryFinalizePackageResult?.(pkg.id);
|
state.tryFinalizePackageResult?.(pkg.id);
|
||||||
await flushNotifications();
|
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(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 () => {
|
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(pkg.status).toBe("failed");
|
||||||
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
|
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 () => {
|
it("creates a new result generation when extraction is retried", async () => {
|
||||||
@@ -463,10 +531,10 @@ describe("authoritative run completion", () => {
|
|||||||
state.tryFinalizePackageResult?.(pkg.id);
|
state.tryFinalizePackageResult?.(pkg.id);
|
||||||
await flushNotifications();
|
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];
|
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 === "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 () => {
|
it("flushes successful package digests before run_completed", async () => {
|
||||||
|
|||||||
@@ -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 items = Array.from({ length: 16 }, (_, index) => downloadItem(`item-${index + 1}`));
|
||||||
const result = finalizePackageResult(telemetry({
|
const result = finalizePackageResult(telemetry({
|
||||||
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
||||||
@@ -149,15 +177,32 @@ describe("package lifecycle telemetry", () => {
|
|||||||
downloadDurationSeconds: 120,
|
downloadDurationSeconds: 120,
|
||||||
extractionDurationSeconds: 30,
|
extractionDurationSeconds: 30,
|
||||||
totalDurationSeconds: 165,
|
totalDurationSeconds: 165,
|
||||||
successfulFiles: 15,
|
successfulFiles: 16,
|
||||||
failedFiles: 1,
|
failedFiles: 0,
|
||||||
partCount: 16,
|
partCount: 16,
|
||||||
archiveCount: 1,
|
archiveCount: 1,
|
||||||
|
extractionFailures: 1,
|
||||||
failurePhase: "extract",
|
failurePhase: "extract",
|
||||||
errorCategory: "Entpacken"
|
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", () => {
|
it("classifies a package with no successful files and a download failure as failed", () => {
|
||||||
const item = downloadItem("item-1", "failed");
|
const item = downloadItem("item-1", "failed");
|
||||||
const result = finalizePackageResult(telemetry({
|
const result = finalizePackageResult(telemetry({
|
||||||
@@ -396,8 +441,8 @@ describe("package lifecycle telemetry", () => {
|
|||||||
|
|
||||||
expect(result).toEqual(expect.objectContaining({
|
expect(result).toEqual(expect.objectContaining({
|
||||||
status: "partial",
|
status: "partial",
|
||||||
successfulFiles: 1,
|
successfulFiles: 2,
|
||||||
failedFiles: 1,
|
failedFiles: 0,
|
||||||
failurePhase: "remux"
|
failurePhase: "remux"
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
+23
-1
@@ -1235,6 +1235,13 @@ describe("settings storage", () => {
|
|||||||
partCount: 16,
|
partCount: 16,
|
||||||
outputCount: 15,
|
outputCount: 15,
|
||||||
failurePhase: "extract",
|
failurePhase: "extract",
|
||||||
|
errorCategory: "checksum",
|
||||||
|
downloadFailures: 2,
|
||||||
|
offlineFailures: 1,
|
||||||
|
extractionFailures: 3,
|
||||||
|
remuxFailures: 4,
|
||||||
|
cleanupFailures: 5,
|
||||||
|
postProcessFailures: 6,
|
||||||
archiveOperations: [{
|
archiveOperations: [{
|
||||||
id: "archive-1",
|
id: "archive-1",
|
||||||
name: "Paket.part01.rar",
|
name: "Paket.part01.rar",
|
||||||
@@ -1274,6 +1281,13 @@ describe("settings storage", () => {
|
|||||||
partCount: 16,
|
partCount: 16,
|
||||||
outputCount: 15,
|
outputCount: 15,
|
||||||
failurePhase: "extract",
|
failurePhase: "extract",
|
||||||
|
errorCategory: "Entpacken",
|
||||||
|
downloadFailures: 2,
|
||||||
|
offlineFailures: 1,
|
||||||
|
extractionFailures: 3,
|
||||||
|
remuxFailures: 4,
|
||||||
|
cleanupFailures: 5,
|
||||||
|
postProcessFailures: 6,
|
||||||
archiveOperations: [{
|
archiveOperations: [{
|
||||||
id: "archive-1",
|
id: "archive-1",
|
||||||
name: "Paket.part01.rar",
|
name: "Paket.part01.rar",
|
||||||
@@ -1332,7 +1346,13 @@ describe("settings storage", () => {
|
|||||||
}],
|
}],
|
||||||
remuxOperations: [],
|
remuxOperations: [],
|
||||||
outputCount: 1,
|
outputCount: 1,
|
||||||
|
outputBaselineSignatures: [
|
||||||
|
"a".repeat(64),
|
||||||
|
"invalid",
|
||||||
|
"b".repeat(64)
|
||||||
|
],
|
||||||
cleanupErrorCategory: "",
|
cleanupErrorCategory: "",
|
||||||
|
postProcessErrorCategory: "rename failed",
|
||||||
createdAt: 1_000,
|
createdAt: 1_000,
|
||||||
updatedAt: 21_000
|
updatedAt: 21_000
|
||||||
}
|
}
|
||||||
@@ -1358,7 +1378,9 @@ describe("settings storage", () => {
|
|||||||
archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })],
|
archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })],
|
||||||
remuxOperations: [],
|
remuxOperations: [],
|
||||||
outputCount: 1,
|
outputCount: 1,
|
||||||
cleanupErrorCategory: ""
|
outputBaselineSignatures: ["a".repeat(64), "b".repeat(64)],
|
||||||
|
cleanupErrorCategory: "",
|
||||||
|
postProcessErrorCategory: "Nachbearbeitung"
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user