feat(history): add package lifecycle telemetry

This commit is contained in:
Sucukdeluxe
2026-08-22 04:04:42 +02:00
parent 161e111c2f
commit ef7423134a
8 changed files with 829 additions and 37 deletions
+135
View File
@@ -0,0 +1,135 @@
import type {
ArchiveOperationMetric,
FailurePhase,
PackageResult,
PackageResultStatus,
PackageTelemetry,
RemuxOperationMetric
} from "../shared/types";
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));
}
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[],
downloadErrors: readonly string[]
): { failurePhase: FailurePhase; errorCategory: string } {
if (cleanupErrorCategory) {
return { failurePhase: "cleanup", errorCategory: cleanupErrorCategory };
}
const failedRemux = remuxOperations.find((operation) => operation.status === "failed");
if (failedRemux || remuxFallbackFailures > 0) {
return { failurePhase: "remux", errorCategory: failedRemux?.errorCategory || "remux" };
}
const failedArchive = archiveOperations.find((operation) => operation.status === "failed");
if (failedArchive) {
return { failurePhase: "extract", errorCategory: failedArchive.errorCategory };
}
const downloadError = downloadErrors.find(Boolean);
if (downloadError) {
return { failurePhase: "download", errorCategory: 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 completedDownloads = telemetry.items.filter((item) => item.status === "completed").length;
const failedDownloads = telemetry.items.filter((item) => item.status === "failed");
const cancelledDownloads = telemetry.items.filter((item) => item.status === "cancelled").length;
const failedArchives = archiveOperations.filter((operation) => operation.status === "failed").length;
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 postProcessFailures = failedArchives + remuxFailures + (cleanupErrorCategory ? 1 : 0);
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 ?? packageEntry.downloadCompletedAt);
const postProcessStartedAt = finiteNonNegative(packageEntry.postProcessStartedAt);
const postProcessCompletedAt = finiteNonNegative(packageEntry.postProcessCompletedAt);
const completedAt = finiteNonNegative(packageEntry.terminalAt);
const downloadedBytes = telemetry.items.reduce((total, item) => total + finiteNonNegative(item.downloadedBytes), 0);
const totalBytes = 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,
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,
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
};
}
+115 -17
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { randomUUID } from "node:crypto";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
import { defaultSettings } from "./constants";
@@ -41,8 +41,11 @@ const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const VALID_OPERATION_STATUSES = new Set(["completed", "failed", "cancelled"]);
const VALID_HISTORY_STATUSES = new Set(["completed", "partial", "failed", "cancelled", "deleted"]);
const VALID_FAILURE_PHASES = new Set(["download", "extract", "remux", "cleanup"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
function asText(value: unknown): string {
return String(value ?? "").trim();
@@ -736,7 +739,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined {
function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined {
const parsed = asRecord(raw);
if (!parsed) {
return undefined;
@@ -764,9 +767,71 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined
skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000),
failed: clampNumber(parsed.failed, 0, 0, 1_000_000),
files
};
}
};
}
function normalizeArchiveOperations(raw: unknown): ArchiveOperationMetric[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.slice(0, 10_000).flatMap((value) => {
const operation = asRecord(value);
if (!operation) {
return [];
}
const id = normalizeSessionId(operation.id);
const status = asText(operation.status);
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
return [];
}
return [{
id,
name: asText(operation.name),
itemIds: Array.isArray(operation.itemIds)
? operation.itemIds.map(normalizeSessionId).filter(Boolean).slice(0, 100_000)
: [],
partCount: clampNumber(operation.partCount, 0, 0, 100_000),
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
status: status as ArchiveOperationMetric["status"],
errorCategory: asText(operation.errorCategory)
}];
});
}
function normalizeRemuxOperations(raw: unknown): RemuxOperationMetric[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.slice(0, 10_000).flatMap((value) => {
const operation = asRecord(value);
if (!operation) {
return [];
}
const id = normalizeSessionId(operation.id);
const status = asText(operation.status);
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
return [];
}
return [{
id,
fileName: asText(operation.fileName),
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
status: status as RemuxOperationMetric["status"],
errorCategory: asText(operation.errorCategory)
}];
});
}
function optionalClampedNumber(record: Record<string, unknown>, key: string, max = Number.MAX_SAFE_INTEGER): number | undefined {
return Object.prototype.hasOwnProperty.call(record, key)
? clampNumber(record[key], 0, 0, max)
: undefined;
}
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
return parsed;
@@ -921,8 +986,17 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
? [...new Set(pkg.cleanedProviders.map((value) => asText(value) as DebridProvider).filter((value) => VALID_ITEM_PROVIDERS.has(value)))]
: [],
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
downloadEndedAt: clampNumber(pkg.downloadEndedAt, 0, 0, Number.MAX_SAFE_INTEGER),
postProcessQueuedAt: clampNumber(pkg.postProcessQueuedAt, 0, 0, Number.MAX_SAFE_INTEGER),
postProcessStartedAt: clampNumber(pkg.postProcessStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
postProcessCompletedAt: clampNumber(pkg.postProcessCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
terminalAt: clampNumber(pkg.terminalAt, 0, 0, Number.MAX_SAFE_INTEGER),
archiveOperations: normalizeArchiveOperations(pkg.archiveOperations),
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000),
cleanupErrorCategory: asText(pkg.cleanupErrorCategory),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
};
}
@@ -1481,9 +1555,27 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
const entry = asRecord(raw);
if (!entry) return null;
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
const name = asText(entry.name) || "Unbenannt";
const providerRaw = asText(entry.provider);
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
const name = asText(entry.name) || "Unbenannt";
const providerRaw = asText(entry.provider);
const statusRaw = asText(entry.status);
const failurePhaseRaw = entry.failurePhase === null ? null : asText(entry.failurePhase);
const optionalFields = {
startedAt: optionalClampedNumber(entry, "startedAt"),
downloadEndedAt: optionalClampedNumber(entry, "downloadEndedAt"),
postProcessStartedAt: optionalClampedNumber(entry, "postProcessStartedAt"),
downloadDurationSeconds: optionalClampedNumber(entry, "downloadDurationSeconds"),
extractionDurationSeconds: optionalClampedNumber(entry, "extractionDurationSeconds"),
remuxDurationSeconds: optionalClampedNumber(entry, "remuxDurationSeconds"),
postProcessDurationSeconds: optionalClampedNumber(entry, "postProcessDurationSeconds"),
totalDurationSeconds: optionalClampedNumber(entry, "totalDurationSeconds"),
successfulFiles: optionalClampedNumber(entry, "successfulFiles", 1_000_000),
failedFiles: optionalClampedNumber(entry, "failedFiles", 1_000_000),
cancelledFiles: optionalClampedNumber(entry, "cancelledFiles", 1_000_000),
archiveCount: optionalClampedNumber(entry, "archiveCount", 100_000),
partCount: optionalClampedNumber(entry, "partCount", 1_000_000),
outputCount: optionalClampedNumber(entry, "outputCount", 1_000_000)
};
return {
id,
@@ -1494,11 +1586,17 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
provider: VALID_ITEM_PROVIDERS.has(providerRaw as DebridProvider) ? providerRaw as DebridProvider : null,
completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER),
durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER),
status: entry.status === "deleted" ? "deleted" : "completed",
outputDir: asText(entry.outputDir),
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined
};
}
status: VALID_HISTORY_STATUSES.has(statusRaw) ? statusRaw as HistoryEntry["status"] : "completed",
outputDir: asText(entry.outputDir),
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined,
...Object.fromEntries(Object.entries(optionalFields).filter(([, value]) => value !== undefined)),
...(failurePhaseRaw === null || VALID_FAILURE_PHASES.has(failurePhaseRaw)
? { failurePhase: failurePhaseRaw as FailurePhase }
: {}),
...(Array.isArray(entry.archiveOperations) ? { archiveOperations: normalizeArchiveOperations(entry.archiveOperations) } : {}),
...(Array.isArray(entry.remuxOperations) ? { remuxOperations: normalizeRemuxOperations(entry.remuxOperations) } : {})
};
}
export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] {
ensureBaseDir(paths.baseDir);