Files
Multi-Debrid-Downloader/src/main/package-telemetry.ts
T
Sucukdeluxe 55b8911e94 fix(downloads): restore actionable package states and extraction controls
Resolve child archive selections to complete multipart sets, support bulk package extraction, refresh extraction passwords at execution time, and keep archive failures scoped by their full paths.

Restore sortable download columns, preserve verified availability, classify package retry and extraction states, secure link copying through the preload bridge, and release cancelled provider work without stale cooldowns.
2026-08-22 22:52:49 +02:00

209 lines
9.9 KiB
TypeScript

import type {
ArchiveOperationMetric,
FailurePhase,
PackageResult,
PackageResultStatus,
PackageTelemetry,
RemuxOperationMetric
} from "../shared/types";
export type PackageFailureCategory =
| "Netzwerk"
| "Timeout"
| "Offline"
| "Speicherplatz"
| "Berechtigung"
| "Download"
| "Entpacken"
| "Remux"
| "Cleanup"
| "Unbekannt";
const packageFailureCategories = new Map<string, PackageFailureCategory>([
["netzwerk", "Netzwerk"],
["timeout", "Timeout"],
["offline", "Offline"],
["speicherplatz", "Speicherplatz"],
["berechtigung", "Berechtigung"],
["download", "Download"],
["entpacken", "Entpacken"],
["remux", "Remux"],
["cleanup", "Cleanup"],
["unbekannt", "Unbekannt"]
]);
function finiteNonNegative(value: unknown): number {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
export function durationMsToSeconds(durationMs: number): number {
return Math.floor(finiteNonNegative(durationMs) / 1_000);
}
export function durationSecondsBetween(startedAt: number | undefined, completedAt: number | undefined): number {
const start = finiteNonNegative(startedAt);
const end = finiteNonNegative(completedAt);
return start > 0 && end > start ? durationMsToSeconds(end - start) : 0;
}
export function sumOperationDurationSeconds(operations: readonly { durationMs: number }[]): number {
return durationMsToSeconds(operations.reduce((total, operation) => total + finiteNonNegative(operation.durationMs), 0));
}
export function projectPackageFailureCategory(failurePhase: FailurePhase, detail: unknown): PackageFailureCategory {
const normalized = String(detail ?? "").trim().slice(0, 2048).toLowerCase();
const knownCategory = packageFailureCategories.get(normalized);
if (knownCategory) {
return knownCategory;
}
if (/\b(?:e?timed?[\s_-]*out|timeout)\b|zeit(?:ü|ue)berschreitung/.test(normalized)) {
return "Timeout";
}
if (/\boffline\b|not[\s_-]*found|nicht[\s_-]*gefunden|dead[\s_-]*link|http\s*404/.test(normalized)) {
return "Offline";
}
if (/\benospc\b|disk[\s_-]*full|no[\s_-]+space[\s_-]+left|not[\s_-]+enough[\s_-]+(?:disk[\s_-]+)?space|insufficient[\s_-]+(?:disk[\s_-]+)?space|speicherplatz|datenträger[^\n]*voll/.test(normalized)) {
return "Speicherplatz";
}
if (/\beacces\b|\beperm\b|permission|access[\s_-]*denied|zugriff[^\n]*verweigert|berechtigung/.test(normalized)) {
return "Berechtigung";
}
if (/network|netzwerk|\beconn|\benet|\behost|\beai_again\b|\bdns\b|socket|connection|verbindung|fetch[\s_-]*failed/.test(normalized)) {
return "Netzwerk";
}
if (failurePhase === "download") return "Download";
if (failurePhase === "extract") return "Entpacken";
if (failurePhase === "remux") return "Remux";
if (failurePhase === "cleanup") return "Cleanup";
return "Unbekannt";
}
function classifyStatus(successfulFiles: number, failedFiles: number, cancelledFiles: number, packageCancelled: boolean): PackageResultStatus {
if (successfulFiles > 0 && (failedFiles > 0 || cancelledFiles > 0 || packageCancelled)) {
return "partial";
}
if (failedFiles > 0) {
return "failed";
}
if (cancelledFiles > 0 || packageCancelled) {
return "cancelled";
}
return "completed";
}
function getFailure(
cleanupErrorCategory: string,
remuxOperations: readonly RemuxOperationMetric[],
remuxFallbackFailures: number,
archiveOperations: readonly ArchiveOperationMetric[],
extractionErrors: readonly string[],
downloadErrors: readonly string[]
): { failurePhase: FailurePhase; errorCategory: string } {
if (cleanupErrorCategory) {
return { failurePhase: "cleanup", errorCategory: projectPackageFailureCategory("cleanup", cleanupErrorCategory) };
}
const failedRemux = remuxOperations.find((operation) => operation.status === "failed");
if (failedRemux || remuxFallbackFailures > 0) {
return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) };
}
const failedArchive = archiveOperations.find((operation) => operation.status === "failed");
if (failedArchive || extractionErrors.length > 0) {
return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive?.errorCategory || extractionErrors[0]) };
}
const downloadError = downloadErrors.find(Boolean);
if (downloadErrors.length > 0) {
return { failurePhase: "download", errorCategory: projectPackageFailureCategory("download", downloadError) };
}
return { failurePhase: null, errorCategory: "" };
}
export function finalizePackageResult(telemetry: PackageTelemetry): PackageResult {
const packageEntry = telemetry.package;
const archiveOperations = (telemetry.archiveOperations ?? packageEntry.archiveOperations ?? [])
.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] }));
const remuxOperations = (telemetry.remuxOperations ?? packageEntry.remuxOperations ?? [])
.map((operation) => ({ ...operation }));
const cleanedCompletedDownloads = Math.max(0, Math.floor(finiteNonNegative(packageEntry.cleanedCompletedItemCount)));
const completedDownloads = cleanedCompletedDownloads + telemetry.items.filter((item) => item.status === "completed").length;
const failedDownloads = telemetry.items.filter((item) => item.status === "failed");
const itemExtractionFailures = telemetry.items.filter((item) => item.status === "completed" && /^(?:Entpack-Fehler|Entpacken\s*-\s*(?:Fehler|Error))/i.test(item.fullStatus || ""));
const itemExtractionFailureCount = new Set(itemExtractionFailures.map((item) => {
const archiveName = String(item.fullStatus || "").match(/^Entpack-Fehler\s*\[([^\]]+)\]/i)?.[1];
return archiveName?.toLocaleLowerCase("de-DE") || String(item.fullStatus || "").toLocaleLowerCase("de-DE") || item.id;
})).size;
const cancelledDownloads = telemetry.items.filter((item) => item.status === "cancelled").length;
const failedArchives = Math.max(archiveOperations.filter((operation) => operation.status === "failed").length, itemExtractionFailureCount);
const cancelledArchives = archiveOperations.filter((operation) => operation.status === "cancelled").length;
const failedRemuxOperations = remuxOperations.filter((operation) => operation.status === "failed").length;
const cancelledRemuxOperations = remuxOperations.filter((operation) => operation.status === "cancelled").length;
const audioStripFailures = Math.max(0, Math.floor(finiteNonNegative(packageEntry.audioStripSummary?.failed)));
const remuxFailures = Math.max(failedRemuxOperations, audioStripFailures);
const cleanupErrorCategory = String(telemetry.cleanupErrorCategory ?? packageEntry.cleanupErrorCategory ?? "").trim();
const downloadFailureCategories = failedDownloads.map((item) =>
projectPackageFailureCategory("download", item.lastError || item.fullStatus)
);
const offlineFailures = downloadFailureCategories.filter((category) => category === "Offline").length;
const cleanupFailures = cleanupErrorCategory ? 1 : 0;
const postProcessFailures = failedArchives + remuxFailures + cleanupFailures;
const postProcessCancellations = cancelledArchives + cancelledRemuxOperations;
const failedFiles = failedDownloads.length + postProcessFailures;
const cancelledFiles = cancelledDownloads + postProcessCancellations;
const successfulFiles = Math.max(0, completedDownloads - postProcessFailures - postProcessCancellations);
const startedAt = finiteNonNegative(packageEntry.downloadStartedAt);
const downloadEndedAt = finiteNonNegative(packageEntry.downloadEndedAt) || finiteNonNegative(packageEntry.downloadCompletedAt);
const postProcessStartedAt = finiteNonNegative(packageEntry.postProcessStartedAt);
const postProcessCompletedAt = finiteNonNegative(packageEntry.postProcessCompletedAt);
const completedAt = finiteNonNegative(packageEntry.terminalAt);
const downloadedBytes = finiteNonNegative(packageEntry.cleanedDownloadedBytes)
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.downloadedBytes), 0);
const totalBytes = finiteNonNegative(packageEntry.cleanedTotalBytes)
+ telemetry.items.reduce((total, item) => total + finiteNonNegative(item.totalBytes ?? item.downloadedBytes), 0);
const downloadDurationSeconds = durationSecondsBetween(startedAt, downloadEndedAt);
const extractionDurationSeconds = sumOperationDurationSeconds(archiveOperations);
const remuxDurationSeconds = sumOperationDurationSeconds(remuxOperations);
const postProcessDurationSeconds = durationSecondsBetween(postProcessStartedAt, postProcessCompletedAt);
const totalDurationSeconds = durationSecondsBetween(startedAt, completedAt);
const failure = getFailure(
cleanupErrorCategory,
remuxOperations,
audioStripFailures,
archiveOperations,
itemExtractionFailures.map((item) => item.lastError || item.fullStatus),
failedDownloads.map((item) => item.lastError || item.fullStatus)
);
return {
packageId: packageEntry.id,
name: packageEntry.name,
status: classifyStatus(successfulFiles, failedFiles, cancelledFiles, packageEntry.cancelled),
startedAt,
downloadEndedAt,
postProcessStartedAt,
completedAt,
downloadDurationSeconds,
extractionDurationSeconds,
remuxDurationSeconds,
postProcessDurationSeconds,
totalDurationSeconds,
totalBytes,
downloadedBytes,
averageDownloadSpeedBps: downloadDurationSeconds > 0 ? Math.floor(downloadedBytes / downloadDurationSeconds) : 0,
successfulFiles,
failedFiles,
cancelledFiles,
downloadFailures: failedDownloads.length,
offlineFailures,
extractionFailures: failedArchives,
remuxFailures,
cleanupFailures,
archiveCount: archiveOperations.length,
partCount: archiveOperations.reduce((total, operation) => total + Math.max(0, Math.floor(finiteNonNegative(operation.partCount))), 0),
outputCount: Math.max(0, Math.floor(finiteNonNegative(telemetry.outputCount ?? packageEntry.outputCount))),
failurePhase: failure.failurePhase,
errorCategory: failure.errorCategory,
archiveOperations,
remuxOperations
};
}