diff --git a/src/main/package-telemetry.ts b/src/main/package-telemetry.ts new file mode 100644 index 0000000..7fa3110 --- /dev/null +++ b/src/main/package-telemetry.ts @@ -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 + }; +} diff --git a/src/main/storage.ts b/src/main/storage.ts index 716520f..565043e 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -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([ "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" ]); const VALID_ITEM_PROVIDERS = new Set(["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 | null { return value as Record; } -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, 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); diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index fc31d0f..8be1a9a 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -116,6 +116,8 @@ const providerLabels: Record = { const statusLabels: Record = { completed: "Abgeschlossen", + partial: "Teilweise", + cancelled: "Abgebrochen", deleted: "Gelöscht", failed: "Fehlgeschlagen" }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 8daad56..c9a0f82 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -470,7 +470,7 @@ export interface AudioStripFileResult { languages?: string; } -export interface AudioStripSummary { +export interface AudioStripSummary { at: number; candidates: number; remuxed: number; @@ -478,10 +478,72 @@ export interface AudioStripSummary { skippedNoGerman: number; skippedNoTool: number; failed: number; - files: AudioStripFileResult[]; -} - -export interface PackageEntry { + files: AudioStripFileResult[]; +} + +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; name: string; outputDir: string; @@ -500,8 +562,17 @@ export interface PackageEntry { cleanedUrls?: string[]; cleanedProviders?: DebridProvider[]; downloadStartedAt?: number; - downloadCompletedAt?: number; - createdAt: number; + downloadCompletedAt?: number; + downloadEndedAt?: number; + postProcessQueuedAt?: number; + postProcessStartedAt?: number; + postProcessCompletedAt?: number; + terminalAt?: number; + archiveOperations?: ArchiveOperationMetric[]; + remuxOperations?: RemuxOperationMetric[]; + outputCount?: number; + cleanupErrorCategory?: string; + createdAt: number; updatedAt: number; } @@ -829,10 +900,27 @@ export interface HistoryEntry { provider: DebridProvider | null; completedAt: number; durationSeconds: number; - status: "completed" | "deleted"; - outputDir: string; - urls?: string[]; -} + status: PackageResultStatus | "deleted"; + outputDir: 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 { entries: HistoryEntry[]; diff --git a/tests/history-reveal.test.ts b/tests/history-reveal.test.ts index a81a39f..4f0aa29 100644 --- a/tests/history-reveal.test.ts +++ b/tests/history-reveal.test.ts @@ -53,6 +53,13 @@ describe("revealHistoryEntry", () => { 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 () => { const deps = dependencies(); diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index bedacb5..6813760 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -133,10 +133,19 @@ describe("history model", () => { for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) { 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 springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime(); const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime(); diff --git a/tests/package-telemetry.test.ts b/tests/package-telemetry.test.ts new file mode 100644 index 0000000..fcff01b --- /dev/null +++ b/tests/package-telemetry.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 121569b..c832261 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -6,7 +6,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; 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 { 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"; @@ -981,7 +981,7 @@ describe("settings storage", () => { 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"); const normalized = normalizeSettings({ @@ -989,9 +989,203 @@ describe("settings storage", () => { 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", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir);