feat(history): add package lifecycle telemetry
This commit is contained in:
@@ -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
@@ -5,7 +5,7 @@ import path from "node:path";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
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 { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||||
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
||||||
import { defaultSettings } from "./constants";
|
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"
|
"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_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 VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
||||||
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
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 {
|
function asText(value: unknown): string {
|
||||||
return String(value ?? "").trim();
|
return String(value ?? "").trim();
|
||||||
@@ -736,7 +739,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined {
|
function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined {
|
||||||
const parsed = asRecord(raw);
|
const parsed = asRecord(raw);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -764,9 +767,71 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined
|
|||||||
skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000),
|
skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000),
|
||||||
failed: clampNumber(parsed.failed, 0, 0, 1_000_000),
|
failed: clampNumber(parsed.failed, 0, 0, 1_000_000),
|
||||||
files
|
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 {
|
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
||||||
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
|
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
|
||||||
return parsed;
|
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)))]
|
? [...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),
|
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||||
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 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),
|
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)
|
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);
|
const entry = asRecord(raw);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
|
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
|
||||||
const name = asText(entry.name) || "Unbenannt";
|
const name = asText(entry.name) || "Unbenannt";
|
||||||
const providerRaw = asText(entry.provider);
|
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 {
|
return {
|
||||||
id,
|
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,
|
provider: VALID_ITEM_PROVIDERS.has(providerRaw as DebridProvider) ? providerRaw as DebridProvider : null,
|
||||||
completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER),
|
completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER),
|
||||||
durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER),
|
durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||||
status: entry.status === "deleted" ? "deleted" : "completed",
|
status: VALID_HISTORY_STATUSES.has(statusRaw) ? statusRaw as HistoryEntry["status"] : "completed",
|
||||||
outputDir: asText(entry.outputDir),
|
outputDir: asText(entry.outputDir),
|
||||||
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined
|
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[] {
|
export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] {
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ const providerLabels: Record<DebridProvider, string> = {
|
|||||||
|
|
||||||
const statusLabels: Record<HistoryViewStatus, string> = {
|
const statusLabels: Record<HistoryViewStatus, string> = {
|
||||||
completed: "Abgeschlossen",
|
completed: "Abgeschlossen",
|
||||||
|
partial: "Teilweise",
|
||||||
|
cancelled: "Abgebrochen",
|
||||||
deleted: "Gelöscht",
|
deleted: "Gelöscht",
|
||||||
failed: "Fehlgeschlagen"
|
failed: "Fehlgeschlagen"
|
||||||
};
|
};
|
||||||
|
|||||||
+99
-11
@@ -470,7 +470,7 @@ export interface AudioStripFileResult {
|
|||||||
languages?: string;
|
languages?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioStripSummary {
|
export interface AudioStripSummary {
|
||||||
at: number;
|
at: number;
|
||||||
candidates: number;
|
candidates: number;
|
||||||
remuxed: number;
|
remuxed: number;
|
||||||
@@ -478,10 +478,72 @@ export interface AudioStripSummary {
|
|||||||
skippedNoGerman: number;
|
skippedNoGerman: number;
|
||||||
skippedNoTool: number;
|
skippedNoTool: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
files: AudioStripFileResult[];
|
files: AudioStripFileResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageEntry {
|
export type PackageResultStatus = "completed" | "partial" | "failed" | "cancelled";
|
||||||
|
export type FailurePhase = "download" | "extract" | "remux" | "cleanup" | null;
|
||||||
|
|
||||||
|
export interface ArchiveOperationMetric {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
itemIds: string[];
|
||||||
|
partCount: number;
|
||||||
|
startedAt: number;
|
||||||
|
completedAt: number;
|
||||||
|
durationMs: number;
|
||||||
|
status: "completed" | "failed" | "cancelled";
|
||||||
|
errorCategory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemuxOperationMetric {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
startedAt: number;
|
||||||
|
completedAt: number;
|
||||||
|
durationMs: number;
|
||||||
|
status: "completed" | "failed" | "cancelled";
|
||||||
|
errorCategory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageTelemetry {
|
||||||
|
package: PackageEntry;
|
||||||
|
items: DownloadItem[];
|
||||||
|
archiveOperations?: ArchiveOperationMetric[];
|
||||||
|
remuxOperations?: RemuxOperationMetric[];
|
||||||
|
outputCount?: number;
|
||||||
|
cleanupErrorCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageResult {
|
||||||
|
packageId: string;
|
||||||
|
name: string;
|
||||||
|
status: PackageResultStatus;
|
||||||
|
startedAt: number;
|
||||||
|
downloadEndedAt: number;
|
||||||
|
postProcessStartedAt: number;
|
||||||
|
completedAt: number;
|
||||||
|
downloadDurationSeconds: number;
|
||||||
|
extractionDurationSeconds: number;
|
||||||
|
remuxDurationSeconds: number;
|
||||||
|
postProcessDurationSeconds: number;
|
||||||
|
totalDurationSeconds: number;
|
||||||
|
totalBytes: number;
|
||||||
|
downloadedBytes: number;
|
||||||
|
averageDownloadSpeedBps: number;
|
||||||
|
successfulFiles: number;
|
||||||
|
failedFiles: number;
|
||||||
|
cancelledFiles: number;
|
||||||
|
archiveCount: number;
|
||||||
|
partCount: number;
|
||||||
|
outputCount: number;
|
||||||
|
failurePhase: FailurePhase;
|
||||||
|
errorCategory: string;
|
||||||
|
archiveOperations: ArchiveOperationMetric[];
|
||||||
|
remuxOperations: RemuxOperationMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageEntry {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
@@ -500,8 +562,17 @@ export interface PackageEntry {
|
|||||||
cleanedUrls?: string[];
|
cleanedUrls?: string[];
|
||||||
cleanedProviders?: DebridProvider[];
|
cleanedProviders?: DebridProvider[];
|
||||||
downloadStartedAt?: number;
|
downloadStartedAt?: number;
|
||||||
downloadCompletedAt?: number;
|
downloadCompletedAt?: number;
|
||||||
createdAt: number;
|
downloadEndedAt?: number;
|
||||||
|
postProcessQueuedAt?: number;
|
||||||
|
postProcessStartedAt?: number;
|
||||||
|
postProcessCompletedAt?: number;
|
||||||
|
terminalAt?: number;
|
||||||
|
archiveOperations?: ArchiveOperationMetric[];
|
||||||
|
remuxOperations?: RemuxOperationMetric[];
|
||||||
|
outputCount?: number;
|
||||||
|
cleanupErrorCategory?: string;
|
||||||
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,10 +900,27 @@ export interface HistoryEntry {
|
|||||||
provider: DebridProvider | null;
|
provider: DebridProvider | null;
|
||||||
completedAt: number;
|
completedAt: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
status: "completed" | "deleted";
|
status: PackageResultStatus | "deleted";
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
urls?: string[];
|
urls?: string[];
|
||||||
}
|
startedAt?: number;
|
||||||
|
downloadEndedAt?: number;
|
||||||
|
postProcessStartedAt?: number;
|
||||||
|
downloadDurationSeconds?: number;
|
||||||
|
extractionDurationSeconds?: number;
|
||||||
|
remuxDurationSeconds?: number;
|
||||||
|
postProcessDurationSeconds?: number;
|
||||||
|
totalDurationSeconds?: number;
|
||||||
|
successfulFiles?: number;
|
||||||
|
failedFiles?: number;
|
||||||
|
cancelledFiles?: number;
|
||||||
|
archiveCount?: number;
|
||||||
|
partCount?: number;
|
||||||
|
outputCount?: number;
|
||||||
|
failurePhase?: FailurePhase;
|
||||||
|
archiveOperations?: ArchiveOperationMetric[];
|
||||||
|
remuxOperations?: RemuxOperationMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HistoryState {
|
export interface HistoryState {
|
||||||
entries: HistoryEntry[];
|
entries: HistoryEntry[];
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ describe("revealHistoryEntry", () => {
|
|||||||
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["partial", "failed", "cancelled"] as const)("opens a %s history result from the same authoritative directory", async (status) => {
|
||||||
|
const deps = dependencies({ loadHistory: () => [historyEntry({ status })] });
|
||||||
|
|
||||||
|
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
|
||||||
|
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores every renderer-supplied field except entryId", async () => {
|
it("ignores every renderer-supplied field except entryId", async () => {
|
||||||
const deps = dependencies();
|
const deps = dependencies();
|
||||||
|
|
||||||
|
|||||||
@@ -133,10 +133,19 @@ describe("history model", () => {
|
|||||||
|
|
||||||
for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) {
|
for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) {
|
||||||
expect(filterHistoryRows(entries, filter, "", now).map((row) => row.id)).toEqual(ids);
|
expect(filterHistoryRows(entries, filter, "", now).map((row) => row.id)).toEqual(ids);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
|
it("labels partial and cancelled package results", () => {
|
||||||
|
const rows = filterHistoryRows([
|
||||||
|
entry({ id: "partial", name: "Teilweise", status: "partial" }),
|
||||||
|
entry({ id: "cancelled", name: "Abgebrochen", status: "cancelled" })
|
||||||
|
], "all", "", now);
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.statusLabel)).toEqual(["Teilweise", "Abgebrochen"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime();
|
const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime();
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
ArchiveOperationMetric,
|
||||||
|
DownloadItem,
|
||||||
|
PackageEntry,
|
||||||
|
PackageTelemetry,
|
||||||
|
RemuxOperationMetric
|
||||||
|
} from "../src/shared/types";
|
||||||
|
import {
|
||||||
|
durationMsToSeconds,
|
||||||
|
durationSecondsBetween,
|
||||||
|
finalizePackageResult
|
||||||
|
} from "../src/main/package-telemetry";
|
||||||
|
|
||||||
|
function packageEntry(overrides: Partial<PackageEntry> = {}): PackageEntry {
|
||||||
|
return {
|
||||||
|
id: "pkg-1",
|
||||||
|
name: "Paket",
|
||||||
|
outputDir: "C:\\Downloads\\Paket",
|
||||||
|
extractDir: "C:\\Downloads\\Paket",
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
downloadStartedAt: 1_000,
|
||||||
|
downloadCompletedAt: 121_000,
|
||||||
|
downloadEndedAt: 121_000,
|
||||||
|
postProcessQueuedAt: 122_000,
|
||||||
|
postProcessStartedAt: 130_000,
|
||||||
|
postProcessCompletedAt: 160_000,
|
||||||
|
terminalAt: 166_000,
|
||||||
|
createdAt: 1_000,
|
||||||
|
updatedAt: 166_000,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadItem(id: string, status: DownloadItem["status"] = "completed"): DownloadItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
packageId: "pkg-1",
|
||||||
|
url: `https://example.test/${id}`,
|
||||||
|
provider: "realdebrid",
|
||||||
|
status,
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: status === "completed" ? 1_000 : 0,
|
||||||
|
totalBytes: 1_000,
|
||||||
|
progressPercent: status === "completed" ? 100 : 0,
|
||||||
|
fileName: `${id}.rar`,
|
||||||
|
targetPath: `C:\\Downloads\\Paket\\${id}.rar`,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: status === "failed" ? "download-error" : "",
|
||||||
|
fullStatus: "",
|
||||||
|
createdAt: 1_000,
|
||||||
|
updatedAt: 121_000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveOperation(overrides: Partial<ArchiveOperationMetric> = {}): ArchiveOperationMetric {
|
||||||
|
return {
|
||||||
|
id: "archive-1",
|
||||||
|
name: "Paket.part01.rar",
|
||||||
|
itemIds: ["item-1"],
|
||||||
|
partCount: 1,
|
||||||
|
startedAt: 130_000,
|
||||||
|
completedAt: 160_000,
|
||||||
|
durationMs: 30_000,
|
||||||
|
status: "completed",
|
||||||
|
errorCategory: "",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function remuxOperation(overrides: Partial<RemuxOperationMetric> = {}): RemuxOperationMetric {
|
||||||
|
return {
|
||||||
|
id: "remux-1",
|
||||||
|
fileName: "episode.mkv",
|
||||||
|
startedAt: 150_000,
|
||||||
|
completedAt: 155_000,
|
||||||
|
durationMs: 5_000,
|
||||||
|
status: "completed",
|
||||||
|
errorCategory: "",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function telemetry(overrides: Partial<PackageTelemetry> = {}): PackageTelemetry {
|
||||||
|
const items = [downloadItem("item-1")];
|
||||||
|
return {
|
||||||
|
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
||||||
|
items,
|
||||||
|
archiveOperations: [],
|
||||||
|
remuxOperations: [],
|
||||||
|
outputCount: 0,
|
||||||
|
cleanupErrorCategory: "",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("package lifecycle telemetry", () => {
|
||||||
|
it("finalizes a successful package from explicit timestamps and operation durations", () => {
|
||||||
|
const items = [downloadItem("item-1"), downloadItem("item-2")];
|
||||||
|
const result = finalizePackageResult(telemetry({
|
||||||
|
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
||||||
|
items,
|
||||||
|
archiveOperations: [archiveOperation({ itemIds: items.map((item) => item.id), partCount: 2 })],
|
||||||
|
remuxOperations: [remuxOperation()],
|
||||||
|
outputCount: 2
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
packageId: "pkg-1",
|
||||||
|
status: "completed",
|
||||||
|
downloadDurationSeconds: 120,
|
||||||
|
extractionDurationSeconds: 30,
|
||||||
|
remuxDurationSeconds: 5,
|
||||||
|
postProcessDurationSeconds: 30,
|
||||||
|
totalDurationSeconds: 165,
|
||||||
|
successfulFiles: 2,
|
||||||
|
failedFiles: 0,
|
||||||
|
cancelledFiles: 0,
|
||||||
|
archiveCount: 1,
|
||||||
|
partCount: 2,
|
||||||
|
outputCount: 2,
|
||||||
|
failurePhase: null,
|
||||||
|
averageDownloadSpeedBps: 16
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a failed 16-part archive as one failed file and produces a partial result", () => {
|
||||||
|
const items = Array.from({ length: 16 }, (_, index) => downloadItem(`item-${index + 1}`));
|
||||||
|
const result = finalizePackageResult(telemetry({
|
||||||
|
package: packageEntry({ itemIds: items.map((item) => item.id) }),
|
||||||
|
items,
|
||||||
|
archiveOperations: [archiveOperation({
|
||||||
|
itemIds: items.map((item) => item.id),
|
||||||
|
partCount: 16,
|
||||||
|
status: "failed",
|
||||||
|
errorCategory: "checksum"
|
||||||
|
})]
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
status: "partial",
|
||||||
|
downloadDurationSeconds: 120,
|
||||||
|
extractionDurationSeconds: 30,
|
||||||
|
totalDurationSeconds: 165,
|
||||||
|
successfulFiles: 15,
|
||||||
|
failedFiles: 1,
|
||||||
|
partCount: 16,
|
||||||
|
archiveCount: 1,
|
||||||
|
failurePhase: "extract",
|
||||||
|
errorCategory: "checksum"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a package with no successful files and a download failure as failed", () => {
|
||||||
|
const item = downloadItem("item-1", "failed");
|
||||||
|
const result = finalizePackageResult(telemetry({
|
||||||
|
package: packageEntry({ status: "failed", itemIds: [item.id] }),
|
||||||
|
items: [item]
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
status: "failed",
|
||||||
|
successfulFiles: 0,
|
||||||
|
failedFiles: 1,
|
||||||
|
cancelledFiles: 0,
|
||||||
|
failurePhase: "download",
|
||||||
|
errorCategory: "download-error"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a package with only cancelled work as cancelled", () => {
|
||||||
|
const item = downloadItem("item-1", "cancelled");
|
||||||
|
const result = finalizePackageResult(telemetry({
|
||||||
|
package: packageEntry({ status: "cancelled", cancelled: true, itemIds: [item.id] }),
|
||||||
|
items: [item]
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
status: "cancelled",
|
||||||
|
successfulFiles: 0,
|
||||||
|
failedFiles: 0,
|
||||||
|
cancelledFiles: 1,
|
||||||
|
failurePhase: null
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports zero postprocess durations when no postprocess phase ran", () => {
|
||||||
|
const result = finalizePackageResult(telemetry({
|
||||||
|
package: packageEntry({
|
||||||
|
downloadCompletedAt: 61_000,
|
||||||
|
downloadEndedAt: 61_000,
|
||||||
|
postProcessQueuedAt: undefined,
|
||||||
|
postProcessStartedAt: undefined,
|
||||||
|
postProcessCompletedAt: undefined,
|
||||||
|
terminalAt: 61_000,
|
||||||
|
updatedAt: 61_000
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
downloadDurationSeconds: 60,
|
||||||
|
extractionDurationSeconds: 0,
|
||||||
|
remuxDurationSeconds: 0,
|
||||||
|
postProcessDurationSeconds: 0,
|
||||||
|
totalDurationSeconds: 60
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses cleanup, remux, extract, and download as deterministic failure precedence", () => {
|
||||||
|
const failedItem = downloadItem("item-1", "failed");
|
||||||
|
const failedArchive = archiveOperation({ status: "failed", errorCategory: "archive-error" });
|
||||||
|
const failedRemux = remuxOperation({ status: "failed", errorCategory: "remux-error" });
|
||||||
|
|
||||||
|
expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux], cleanupErrorCategory: "cleanup-error" })).failurePhase).toBe("cleanup");
|
||||||
|
expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux] })).failurePhase).toBe("remux");
|
||||||
|
expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive] })).failurePhase).toBe("extract");
|
||||||
|
expect(finalizePackageResult(telemetry({ items: [failedItem] })).failurePhase).toBe("download");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses audio-strip outcomes when no individual remux operation was recorded", () => {
|
||||||
|
const items = [downloadItem("item-1"), downloadItem("item-2")];
|
||||||
|
const result = finalizePackageResult(telemetry({
|
||||||
|
package: packageEntry({
|
||||||
|
itemIds: items.map((item) => item.id),
|
||||||
|
audioStripSummary: {
|
||||||
|
at: 160_000,
|
||||||
|
candidates: 2,
|
||||||
|
remuxed: 1,
|
||||||
|
keptSingle: 0,
|
||||||
|
skippedNoGerman: 0,
|
||||||
|
skippedNoTool: 0,
|
||||||
|
failed: 1,
|
||||||
|
files: []
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
items
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
status: "partial",
|
||||||
|
successfulFiles: 1,
|
||||||
|
failedFiles: 1,
|
||||||
|
failurePhase: "remux"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps invalid and reversed durations instead of producing negative or non-finite values", () => {
|
||||||
|
expect(durationMsToSeconds(1_999)).toBe(1);
|
||||||
|
expect(durationMsToSeconds(-1)).toBe(0);
|
||||||
|
expect(durationMsToSeconds(Number.NaN)).toBe(0);
|
||||||
|
expect(durationSecondsBetween(5_000, 4_000)).toBe(0);
|
||||||
|
expect(durationSecondsBetween(undefined, 5_000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+199
-5
@@ -6,7 +6,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||||
import { AppSettings } from "../src/shared/types";
|
import { AppSettings } from "../src/shared/types";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||||
@@ -981,7 +981,7 @@ describe("settings storage", () => {
|
|||||||
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
|
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults history retention to permanent and normalizes invalid values", () => {
|
it("defaults history retention to permanent and normalizes invalid values", () => {
|
||||||
expect(defaultSettings().historyRetentionMode).toBe("permanent");
|
expect(defaultSettings().historyRetentionMode).toBe("permanent");
|
||||||
|
|
||||||
const normalized = normalizeSettings({
|
const normalized = normalizeSettings({
|
||||||
@@ -989,9 +989,203 @@ describe("settings storage", () => {
|
|||||||
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
|
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(normalized.historyRetentionMode).toBe("permanent");
|
expect(normalized.historyRetentionMode).toBe("permanent");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads legacy history without inventing structured durations", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||||
|
id: "legacy",
|
||||||
|
name: "Altbestand",
|
||||||
|
totalBytes: 1_000,
|
||||||
|
downloadedBytes: 1_000,
|
||||||
|
fileCount: 1,
|
||||||
|
provider: "realdebrid",
|
||||||
|
completedAt: 10_000,
|
||||||
|
durationSeconds: 9,
|
||||||
|
status: "completed",
|
||||||
|
outputDir: "C:\\Downloads\\Altbestand"
|
||||||
|
}]), "utf8");
|
||||||
|
|
||||||
|
const [loaded] = loadHistory(paths);
|
||||||
|
|
||||||
|
expect(loaded).toEqual(expect.objectContaining({
|
||||||
|
durationSeconds: 9,
|
||||||
|
status: "completed"
|
||||||
|
}));
|
||||||
|
expect(loaded.downloadDurationSeconds).toBeUndefined();
|
||||||
|
expect(loaded.totalDurationSeconds).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["completed", "partial", "failed", "cancelled", "deleted"] as const)("preserves the %s history status", (status) => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||||
|
id: `history-${status}`,
|
||||||
|
name: "Paket",
|
||||||
|
completedAt: 10_000,
|
||||||
|
durationSeconds: 1,
|
||||||
|
status,
|
||||||
|
outputDir: "C:\\Downloads\\Paket"
|
||||||
|
}]), "utf8");
|
||||||
|
|
||||||
|
expect(loadHistory(paths)[0]?.status).toBe(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps expanded history metrics and preserves normalized operations", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
fs.writeFileSync(paths.historyFile, JSON.stringify([{
|
||||||
|
id: "structured",
|
||||||
|
name: "Paket",
|
||||||
|
completedAt: 50_000,
|
||||||
|
durationSeconds: 1,
|
||||||
|
status: "failed",
|
||||||
|
outputDir: "C:\\Downloads\\Paket",
|
||||||
|
startedAt: -10,
|
||||||
|
downloadEndedAt: 20_000,
|
||||||
|
postProcessStartedAt: 25_000,
|
||||||
|
downloadDurationSeconds: -120,
|
||||||
|
extractionDurationSeconds: 30,
|
||||||
|
remuxDurationSeconds: 5,
|
||||||
|
postProcessDurationSeconds: 35,
|
||||||
|
totalDurationSeconds: 50,
|
||||||
|
successfulFiles: -1,
|
||||||
|
failedFiles: 1,
|
||||||
|
cancelledFiles: 0,
|
||||||
|
archiveCount: 1,
|
||||||
|
partCount: 16,
|
||||||
|
outputCount: 15,
|
||||||
|
failurePhase: "extract",
|
||||||
|
archiveOperations: [{
|
||||||
|
id: "archive-1",
|
||||||
|
name: "Paket.part01.rar",
|
||||||
|
itemIds: ["item-1", "", "item-2"],
|
||||||
|
partCount: -16,
|
||||||
|
startedAt: 25_000,
|
||||||
|
completedAt: 50_000,
|
||||||
|
durationMs: -30_000,
|
||||||
|
status: "failed",
|
||||||
|
errorCategory: "checksum"
|
||||||
|
}],
|
||||||
|
remuxOperations: [{
|
||||||
|
id: "remux-1",
|
||||||
|
fileName: "episode.mkv",
|
||||||
|
startedAt: 30_000,
|
||||||
|
completedAt: 35_000,
|
||||||
|
durationMs: 5_000,
|
||||||
|
status: "cancelled",
|
||||||
|
errorCategory: "cancelled"
|
||||||
|
}]
|
||||||
|
}]), "utf8");
|
||||||
|
|
||||||
|
expect(loadHistory(paths)[0]).toEqual(expect.objectContaining({
|
||||||
|
status: "failed",
|
||||||
|
startedAt: 0,
|
||||||
|
downloadEndedAt: 20_000,
|
||||||
|
postProcessStartedAt: 25_000,
|
||||||
|
downloadDurationSeconds: 0,
|
||||||
|
extractionDurationSeconds: 30,
|
||||||
|
remuxDurationSeconds: 5,
|
||||||
|
postProcessDurationSeconds: 35,
|
||||||
|
totalDurationSeconds: 50,
|
||||||
|
successfulFiles: 0,
|
||||||
|
failedFiles: 1,
|
||||||
|
cancelledFiles: 0,
|
||||||
|
archiveCount: 1,
|
||||||
|
partCount: 16,
|
||||||
|
outputCount: 15,
|
||||||
|
failurePhase: "extract",
|
||||||
|
archiveOperations: [{
|
||||||
|
id: "archive-1",
|
||||||
|
name: "Paket.part01.rar",
|
||||||
|
itemIds: ["item-1", "item-2"],
|
||||||
|
partCount: 0,
|
||||||
|
startedAt: 25_000,
|
||||||
|
completedAt: 50_000,
|
||||||
|
durationMs: 0,
|
||||||
|
status: "failed",
|
||||||
|
errorCategory: "checksum"
|
||||||
|
}],
|
||||||
|
remuxOperations: [{
|
||||||
|
id: "remux-1",
|
||||||
|
fileName: "episode.mkv",
|
||||||
|
startedAt: 30_000,
|
||||||
|
completedAt: 35_000,
|
||||||
|
durationMs: 5_000,
|
||||||
|
status: "cancelled",
|
||||||
|
errorCategory: "cancelled"
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves package lifecycle timestamps and operation metrics when loading a session", () => {
|
||||||
|
const normalized = normalizeLoadedSession({
|
||||||
|
version: 2,
|
||||||
|
packageOrder: ["pkg-1"],
|
||||||
|
packages: {
|
||||||
|
"pkg-1": {
|
||||||
|
id: "pkg-1",
|
||||||
|
name: "Paket",
|
||||||
|
outputDir: "C:\\Downloads\\Paket",
|
||||||
|
extractDir: "C:\\Downloads\\Paket",
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
downloadStartedAt: 1_000,
|
||||||
|
downloadCompletedAt: 10_000,
|
||||||
|
downloadEndedAt: 12_000,
|
||||||
|
postProcessQueuedAt: 13_000,
|
||||||
|
postProcessStartedAt: 14_000,
|
||||||
|
postProcessCompletedAt: 20_000,
|
||||||
|
terminalAt: 21_000,
|
||||||
|
archiveOperations: [{
|
||||||
|
id: "archive-1",
|
||||||
|
name: "Paket.rar",
|
||||||
|
itemIds: [],
|
||||||
|
partCount: 1,
|
||||||
|
startedAt: 14_000,
|
||||||
|
completedAt: 18_000,
|
||||||
|
durationMs: 4_000,
|
||||||
|
status: "completed",
|
||||||
|
errorCategory: ""
|
||||||
|
}],
|
||||||
|
remuxOperations: [],
|
||||||
|
outputCount: 1,
|
||||||
|
cleanupErrorCategory: "",
|
||||||
|
createdAt: 1_000,
|
||||||
|
updatedAt: 21_000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: {},
|
||||||
|
runStartedAt: 1_000,
|
||||||
|
totalDownloadedBytes: 0,
|
||||||
|
summaryText: "",
|
||||||
|
reconnectUntil: 0,
|
||||||
|
reconnectReason: "",
|
||||||
|
paused: false,
|
||||||
|
running: false,
|
||||||
|
updatedAt: 21_000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({
|
||||||
|
downloadEndedAt: 12_000,
|
||||||
|
postProcessQueuedAt: 13_000,
|
||||||
|
postProcessStartedAt: 14_000,
|
||||||
|
postProcessCompletedAt: 20_000,
|
||||||
|
terminalAt: 21_000,
|
||||||
|
archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })],
|
||||||
|
remuxOperations: [],
|
||||||
|
outputCount: 1,
|
||||||
|
cleanupErrorCategory: ""
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("skips adding persisted history entries when history retention is never", () => {
|
it("skips adding persisted history entries when history retention is never", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|||||||
Reference in New Issue
Block a user