diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 0be6d5c..04e6650 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -71,6 +71,7 @@ import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types" import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; import { overlayLiveUsageCounters } from "./settings-live-overlay"; import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage"; +import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -882,7 +883,8 @@ export class AppController { appVersion: APP_VERSION, exportedAt: new Date().toISOString(), session: this.manager.getSession(), - history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()), + history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()), + statistics: this.manager.getStats().statistics!, remoteDiagnostics }); const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase); @@ -1001,7 +1003,11 @@ export class AppController { const restoredSession = normalizeLoadedSessionTransientFields( normalizeLoadedSession(parsed.session) ); - saveSession(this.storagePaths, restoredSession); + saveSession(this.storagePaths, restoredSession); + + if (parsed.statistics) { + saveStatisticsLedger(this.storagePaths.statisticsFile, normalizeStatisticsLedger(parsed.statistics)); + } if (Array.isArray(parsed.history) && parsed.history.length > 0) { const normalizedHistory = (parsed.history as unknown[]) diff --git a/src/main/backup-payload.ts b/src/main/backup-payload.ts index 9abc2b4..fca4141 100644 --- a/src/main/backup-payload.ts +++ b/src/main/backup-payload.ts @@ -1,4 +1,4 @@ -import type { AppSettings, SessionState, HistoryEntry } from "../shared/types"; +import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../shared/types"; export type BackupKind = "full" | "settings-only"; @@ -15,7 +15,8 @@ export interface BackupPayload { exportedAt: string; settings: AppSettings; session?: SessionState; - history?: HistoryEntry[]; + history?: HistoryEntry[]; + statistics?: StatisticsLedger; remoteDiagnostics?: BackupRemoteDiagnostics; } @@ -25,7 +26,8 @@ export interface BuildBackupInput { exportedAt: string; /** Only bundled when includeDownloads is true. */ session: SessionState; - history: HistoryEntry[]; + history: HistoryEntry[]; + statistics?: StatisticsLedger; remoteDiagnostics?: BackupRemoteDiagnostics; } @@ -46,7 +48,10 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload { }; if (includeDownloads) { base.session = input.session; - base.history = input.history; + base.history = input.history; + if (input.statistics) { + base.statistics = input.statistics; + } } if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) { base.remoteDiagnostics = sanitizeBackupRemoteDiagnostics(input.remoteDiagnostics); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index eb0756f..862233e 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -17,8 +17,9 @@ import { PackageEntry, PackagePriority, ParsedPackageInput, - SessionState, - StartConflictEntry, + SessionState, + StatisticsLedger, + StartConflictEntry, StartConflictResolutionResult, UiSnapshot, DebridAccountStatus } from "../shared/types"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; @@ -73,6 +74,16 @@ import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, import { mergeKnownTotalBytes } from "./download-size"; import { DiskCapacityError, DiskReservationCoordinator, type DiskReservationLease } from "./disk-space"; import { createRendererState } from "./renderer-state"; +import { + addStatisticsActiveIntervalInPlace, + addStatisticsBytesInPlace, + addStatisticsOutcomeInPlace, + createStatisticsLedger, + loadStatisticsLedger, + normalizeStatisticsLedger, + saveStatisticsLedger, + seedStatisticsDayProviderBytes +} from "./statistics-ledger"; type ActiveTask = { itemId: string; @@ -1769,7 +1780,19 @@ export class DownloadManager extends EventEmitter { private statsCache: DownloadStats | null = null; - private statsCacheAt = 0; + private statsCacheAt = 0; + + private statisticsLedger: StatisticsLedger; + + private statisticsDirty = false; + + private statisticsUrgent = false; + + private lastStatisticsPersistAt = 0; + + private statisticsActivityAt = 0; + + private statisticsActivityWasActive = false; private settingsSnapshotCache: ReturnType | null = null; private settingsSnapshotCacheAt = 0; @@ -1898,7 +1921,12 @@ export class DownloadManager extends EventEmitter { this.runtimePersistedAt = startedAt; this.session = session; this.itemCount = Object.keys(this.session.items).length; - this.storagePaths = storagePaths; + this.storagePaths = storagePaths; + this.statisticsLedger = seedStatisticsDayProviderBytes( + loadStatisticsLedger(storagePaths.statisticsFile, startedAt), + settings.providerDailyUsageBytes, + startedAt + ); this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber); if (this.protectAgainstEmptyClobber) { logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen"); @@ -2544,8 +2572,9 @@ export class DownloadManager extends EventEmitter { sessionStartedAt: this.session.runStartedAt, appSessionStartedAt: this.appSessionStartedAt, sessionRuntimeMs: this.getAppSessionRuntimeMs(now), - totalRuntimeMs: this.getLiveTotalRuntimeMs(now), - runtimeMeasuredAt: now + totalRuntimeMs: this.getLiveTotalRuntimeMs(now), + runtimeMeasuredAt: now, + statistics: normalizeStatisticsLedger(this.statisticsLedger, now) }; this.statsCache = stats; this.statsCacheAt = now; @@ -2744,11 +2773,15 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } - public resetDownloadStats(): void { + public resetDownloadStats(): void { this.settings.totalDownloadedAllTime = 0; this.settings.totalCompletedFilesAllTime = 0; this.settings.providerTotalUsageBytes = {}; - this.settings.debridLinkApiKeyTotalUsageBytes = {}; + this.settings.debridLinkApiKeyTotalUsageBytes = {}; + this.statisticsLedger = createStatisticsLedger(); + this.statisticsDirty = false; + this.statisticsUrgent = false; + saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger); this.lastSettingsPersistAt = nowMs(); saveSettings(this.storagePaths, this.settings); this.invalidateStatsCache(); @@ -5992,8 +6025,9 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } - public prepareForShutdown(): void { - logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); + public prepareForShutdown(): void { + logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); + this.updateStatisticsActivity(nowMs()); this.rotationListenerActive = false; this.clearPersistTimer(); if (this.stateEmitTimer) { @@ -6070,7 +6104,8 @@ export class DownloadManager extends EventEmitter { if (!this.guardBlocksSessionSave()) { saveSession(this.storagePaths, this.session); } - saveSettings(this.storagePaths, this.settings); + saveSettings(this.storagePaths, this.settings); + saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger); } else { logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`); } @@ -6398,11 +6433,22 @@ export class DownloadManager extends EventEmitter { if (!this.guardBlocksSessionSave()) { void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); } - if (now - this.lastSettingsPersistAt >= 30000) { + if (now - this.lastSettingsPersistAt >= 30000) { this.foldRuntimeIntoSettings(now); this.lastSettingsPersistAt = now; - void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`)); - } + void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`)); + } + if (this.statisticsDirty && (this.statisticsUrgent || now - this.lastStatisticsPersistAt >= 10000)) { + this.lastStatisticsPersistAt = now; + this.statisticsDirty = false; + this.statisticsUrgent = false; + try { + saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger); + } catch (error) { + this.statisticsDirty = true; + logger.warn(`Statistik konnte nicht gespeichert werden: ${compactErrorText(error)}`); + } + } } public persistNowSync(): void { @@ -6414,7 +6460,8 @@ export class DownloadManager extends EventEmitter { if (!this.guardBlocksSessionSave()) { saveSession(this.storagePaths, this.session); } - saveSettings(this.storagePaths, this.settings); + saveSettings(this.storagePaths, this.settings); + saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger); } private emitState(force = false): void { @@ -6507,8 +6554,15 @@ export class DownloadManager extends EventEmitter { if (!this.runItemIds.has(itemId)) { return; } - const previous = this.runOutcomes.get(itemId); - this.runOutcomes.set(itemId, status); + const previous = this.runOutcomes.get(itemId); + this.runOutcomes.set(itemId, status); + const item = this.session.items[itemId]; + const provider = item?.provider ? resolveMegaDebridProvider(this.settings, item.provider) : null; + if ((status === "completed" || status === "failed") && previous !== status) { + addStatisticsOutcomeInPlace(this.statisticsLedger, provider, status); + this.statisticsDirty = true; + this.statisticsUrgent = true; + } if (status === "completed" && previous !== "completed") { this.sessionCompletedFiles += 1; this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1; @@ -8105,7 +8159,9 @@ export class DownloadManager extends EventEmitter { if (!provider) { return; } - const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; + const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; + addStatisticsBytesInPlace(this.statisticsLedger, effectiveProvider, byteDelta); + this.statisticsDirty = true; const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta); const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta); this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay; @@ -8169,6 +8225,17 @@ export class DownloadManager extends EventEmitter { return false; } + private updateStatisticsActivity(now: number): void { + if (this.statisticsActivityAt > 0 && this.statisticsActivityWasActive && now > this.statisticsActivityAt) { + addStatisticsActiveIntervalInPlace(this.statisticsLedger, this.statisticsActivityAt, now); + this.statisticsDirty = true; + } + this.statisticsActivityAt = now; + this.statisticsActivityWasActive = this.session.running + && !this.session.paused + && [...this.activeTasks.values()].some((task) => this.session.items[task.itemId]?.status === "downloading"); + } + private hasUsableDownloadAccount(): boolean { return DOWNLOAD_ACCOUNT_PROVIDERS.some((provider) => this.isProviderConfigured(provider)); } @@ -8590,8 +8657,9 @@ export class DownloadManager extends EventEmitter { const myGeneration = this.schedulerGeneration; logger.info(`Scheduler gestartet (gen=${myGeneration})`); try { - while (this.session.running && this.schedulerGeneration === myGeneration) { - const now = nowMs(); + while (this.session.running && this.schedulerGeneration === myGeneration) { + const now = nowMs(); + this.updateStatisticsActivity(now); if (now - this.lastSchedulerHeartbeatAt >= 60000) { this.lastSchedulerHeartbeatAt = now; logger.info(`Scheduler Heartbeat: active=${this.activeTasks.size}, queued=${this.countQueuedItems()}, reconnect=${this.reconnectActive()}, paused=${this.session.paused}, postProcess=${this.packagePostProcessTasks.size}`); @@ -8624,10 +8692,12 @@ export class DownloadManager extends EventEmitter { if (!next) { break; } - this.startItem(next.packageId, next.itemId); - } - - this.runGlobalStallWatchdog(now); + this.startItem(next.packageId, next.itemId); + } + + this.updateStatisticsActivity(nowMs()); + + this.runGlobalStallWatchdog(now); const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false }; const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed; @@ -8640,9 +8710,10 @@ export class DownloadManager extends EventEmitter { const maxParallel = Math.max(1, this.settings.maxParallel); const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120; await sleep(schedulerSleepMs); - } - } finally { - this.scheduleRunning = false; + } + } finally { + this.updateStatisticsActivity(nowMs()); + this.scheduleRunning = false; logger.info(`Scheduler beendet (gen=${myGeneration})`); // Stop->Start race: a new run can begin while this loop sleeps (start()'s // ensureScheduler early-returns on scheduleRunning, then this loop exits on diff --git a/src/main/statistics-ledger.ts b/src/main/statistics-ledger.ts new file mode 100644 index 0000000..ff696f7 --- /dev/null +++ b/src/main/statistics-ledger.ts @@ -0,0 +1,340 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; +import type { + DebridProvider, + StatisticsDayBucket, + StatisticsLedger, + StatisticsProviderBucket +} from "../shared/types"; +export { aggregateStatisticsRange } from "../shared/statistics-aggregation"; + +const providers = new Set([ + "realdebrid", + "megadebrid", + "megadebrid-api", + "megadebrid-web", + "bestdebrid", + "alldebrid", + "ddownload", + "onefichier", + "debridlink", + "linksnappy" +]); + +const renameRetryDelaysMs = [15, 40, 90]; + +function finiteNonNegative(value: unknown): number { + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function emptyProviderBucket(): StatisticsProviderBucket { + return { bytes: 0, completed: 0, failed: 0 }; +} + +function emptyDay(day: string): StatisticsDayBucket { + return { + day, + downloadedBytes: 0, + measuredBytes: 0, + completedFiles: 0, + failedFiles: 0, + activeDownloadMs: 0, + providers: {} + }; +} + +function normalizeProviderBucket(value: unknown): StatisticsProviderBucket { + const record = asRecord(value); + return { + bytes: finiteNonNegative(record?.bytes), + completed: finiteNonNegative(record?.completed), + failed: finiteNonNegative(record?.failed) + }; +} + +function normalizeDay(value: unknown): StatisticsDayBucket | null { + const record = asRecord(value); + const day = String(record?.day || ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { + return null; + } + const rawProviders = asRecord(record?.providers); + const normalizedProviders: Partial> = {}; + for (const [provider, bucket] of Object.entries(rawProviders ?? {})) { + if (providers.has(provider as DebridProvider)) { + normalizedProviders[provider as DebridProvider] = normalizeProviderBucket(bucket); + } + } + return { + day, + downloadedBytes: finiteNonNegative(record?.downloadedBytes), + measuredBytes: finiteNonNegative(record?.measuredBytes), + completedFiles: finiteNonNegative(record?.completedFiles), + failedFiles: finiteNonNegative(record?.failedFiles), + activeDownloadMs: finiteNonNegative(record?.activeDownloadMs), + providers: normalizedProviders + }; +} + +export function createStatisticsLedger(now = Date.now()): StatisticsLedger { + return { version: 1, startedAt: now, days: [] }; +} + +export function normalizeStatisticsLedger(value: unknown, now = Date.now()): StatisticsLedger { + const record = asRecord(value); + if (!record) { + return createStatisticsLedger(now); + } + const byDay = new Map(); + for (const value of Array.isArray(record.days) ? record.days : []) { + const day = normalizeDay(value); + if (day) { + byDay.set(day.day, day); + } + } + return { + version: 1, + startedAt: finiteNonNegative(record.startedAt) || now, + days: [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + }; +} + +function updateDay( + ledger: StatisticsLedger, + epochMs: number, + update: (day: StatisticsDayBucket) => void +): StatisticsLedger { + const normalized = normalizeStatisticsLedger(ledger, epochMs); + const key = getProviderUsageDayKey(epochMs); + const days = normalized.days.map((day) => ({ ...day, providers: { ...day.providers } })); + let day = days.find((entry) => entry.day === key); + if (!day) { + day = emptyDay(key); + days.push(day); + days.sort((left, right) => left.day.localeCompare(right.day)); + } + update(day); + return { ...normalized, days }; +} + +function mutableDay(ledger: StatisticsLedger, epochMs: number): StatisticsDayBucket { + const key = getProviderUsageDayKey(epochMs); + let day = ledger.days.find((entry) => entry.day === key); + if (!day) { + day = emptyDay(key); + ledger.days.push(day); + ledger.days.sort((left, right) => left.day.localeCompare(right.day)); + } + return day; +} + +export function seedStatisticsDayProviderBytes( + ledger: StatisticsLedger, + usage: Partial>, + epochMs = Date.now() +): StatisticsLedger { + return updateDay(ledger, epochMs, (day) => { + for (const [provider, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) { + if (!providers.has(provider)) continue; + const bytes = finiteNonNegative(rawBytes); + const existing = day.providers[provider] ?? emptyProviderBucket(); + existing.bytes = Math.max(existing.bytes, bytes); + day.providers[provider] = existing; + } + day.downloadedBytes = Math.max( + day.downloadedBytes, + Object.values(day.providers).reduce((total, bucket) => total + finiteNonNegative(bucket?.bytes), 0) + ); + }); +} + +export function recordStatisticsBytes( + ledger: StatisticsLedger, + provider: DebridProvider, + byteDelta: number, + epochMs = Date.now() +): StatisticsLedger { + const bytes = finiteNonNegative(byteDelta); + if (bytes <= 0 || !providers.has(provider)) { + return normalizeStatisticsLedger(ledger, epochMs); + } + return updateDay(ledger, epochMs, (day) => { + day.downloadedBytes += bytes; + day.measuredBytes += bytes; + const bucket = day.providers[provider] ?? emptyProviderBucket(); + bucket.bytes += bytes; + day.providers[provider] = bucket; + }); +} + +export function addStatisticsBytesInPlace( + ledger: StatisticsLedger, + provider: DebridProvider, + byteDelta: number, + epochMs = Date.now() +): void { + const bytes = finiteNonNegative(byteDelta); + if (bytes <= 0 || !providers.has(provider)) return; + const day = mutableDay(ledger, epochMs); + day.downloadedBytes += bytes; + day.measuredBytes += bytes; + const bucket = day.providers[provider] ?? emptyProviderBucket(); + bucket.bytes += bytes; + day.providers[provider] = bucket; +} + +export function recordStatisticsOutcome( + ledger: StatisticsLedger, + provider: DebridProvider | null | undefined, + outcome: "completed" | "failed", + epochMs = Date.now() +): StatisticsLedger { + return updateDay(ledger, epochMs, (day) => { + if (outcome === "completed") { + day.completedFiles += 1; + } else { + day.failedFiles += 1; + } + if (!provider || !providers.has(provider)) { + return; + } + const bucket = day.providers[provider] ?? emptyProviderBucket(); + bucket[outcome] += 1; + day.providers[provider] = bucket; + }); +} + +export function addStatisticsOutcomeInPlace( + ledger: StatisticsLedger, + provider: DebridProvider | null | undefined, + outcome: "completed" | "failed", + epochMs = Date.now() +): void { + const day = mutableDay(ledger, epochMs); + day[outcome === "completed" ? "completedFiles" : "failedFiles"] += 1; + if (!provider || !providers.has(provider)) return; + const bucket = day.providers[provider] ?? emptyProviderBucket(); + bucket[outcome] += 1; + day.providers[provider] = bucket; +} + +function startOfNextLocalDay(epochMs: number): number { + const date = new Date(epochMs); + date.setHours(24, 0, 0, 0); + return date.getTime(); +} + +export function recordStatisticsActiveInterval( + ledger: StatisticsLedger, + startMs: number, + endMs: number +): StatisticsLedger { + let next = normalizeStatisticsLedger(ledger, startMs); + let cursor = finiteNonNegative(startMs); + const end = finiteNonNegative(endMs); + while (cursor < end) { + const boundary = Math.min(end, startOfNextLocalDay(cursor)); + const duration = Math.max(0, boundary - cursor); + next = updateDay(next, cursor, (day) => { + day.activeDownloadMs += duration; + }); + cursor = boundary; + } + return next; +} + +export function addStatisticsActiveIntervalInPlace( + ledger: StatisticsLedger, + startMs: number, + endMs: number +): void { + let cursor = finiteNonNegative(startMs); + const end = finiteNonNegative(endMs); + while (cursor < end) { + const boundary = Math.min(end, startOfNextLocalDay(cursor)); + mutableDay(ledger, cursor).activeDownloadMs += Math.max(0, boundary - cursor); + cursor = boundary; + } +} + +export function loadStatisticsLedger(filePath: string, now = Date.now()): StatisticsLedger { + try { + if (!fs.existsSync(filePath)) { + return createStatisticsLedger(now); + } + return normalizeStatisticsLedger(JSON.parse(fs.readFileSync(filePath, "utf8")), now); + } catch { + return createStatisticsLedger(now); + } +} + +function statisticsPayload(ledger: StatisticsLedger): string { + return JSON.stringify(normalizeStatisticsLedger(ledger), null, 2); +} + +function renameErrorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "") + : ""; +} + +function isTransientRenameError(error: unknown): boolean { + return ["EPERM", "EACCES", "EBUSY"].includes(renameErrorCode(error)); +} + +function renameStatisticsFileSync(tempPath: string, filePath: string): void { + for (let attempt = 0; ; attempt += 1) { + try { + fs.renameSync(tempPath, filePath); + return; + } catch (error) { + if (!isTransientRenameError(error) || attempt >= renameRetryDelaysMs.length) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, renameRetryDelaysMs[attempt]); + } + } +} + +async function renameStatisticsFile(tempPath: string, filePath: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await fsp.rename(tempPath, filePath); + return; + } catch (error) { + if (!isTransientRenameError(error) || attempt >= renameRetryDelaysMs.length) throw error; + await new Promise((resolve) => setTimeout(resolve, renameRetryDelaysMs[attempt])); + } + } +} + +export function saveStatisticsLedger(filePath: string, ledger: StatisticsLedger): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + try { + fs.writeFileSync(tempPath, statisticsPayload(ledger), "utf8"); + renameStatisticsFileSync(tempPath, filePath); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch {} + throw error; + } +} + +export async function saveStatisticsLedgerAsync(filePath: string, ledger: StatisticsLedger): Promise { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.async.tmp`; + try { + await fsp.writeFile(tempPath, statisticsPayload(ledger), "utf8"); + await renameStatisticsFile(tempPath, filePath); + } catch (error) { + try { await fsp.rm(tempPath, { force: true }); } catch {} + throw error; + } +} diff --git a/src/main/storage.ts b/src/main/storage.ts index 0c1b1ff..8cfeddb 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -632,7 +632,8 @@ export interface StoragePaths { baseDir: string; configFile: string; sessionFile: string; - historyFile: string; + historyFile: string; + statisticsFile: string; } export function createStoragePaths(baseDir: string): StoragePaths { @@ -640,7 +641,8 @@ export function createStoragePaths(baseDir: string): StoragePaths { baseDir, configFile: path.join(baseDir, "rd_downloader_config.json"), sessionFile: path.join(baseDir, "rd_session_state.json"), - historyFile: path.join(baseDir, "rd_history.json") + historyFile: path.join(baseDir, "rd_history.json"), + statisticsFile: path.join(baseDir, "rd_statistics.json") }; } diff --git a/src/renderer/views/history/HistoryView.tsx b/src/renderer/views/history/HistoryView.tsx index 1e2796b..4601aa5 100644 --- a/src/renderer/views/history/HistoryView.tsx +++ b/src/renderer/views/history/HistoryView.tsx @@ -1,4 +1,13 @@ -import { useEffect, useState, type ChangeEvent, type MouseEvent, type ReactElement } from "react"; +import { + useEffect, + useState, + type ChangeEvent, + type KeyboardEvent, + type MouseEvent, + type PointerEvent, + type ReactElement, + type UIEvent +} from "react"; import { DataTable, DataTableBody, @@ -7,7 +16,20 @@ import { } from "../../ui/DataTable"; import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar"; import { SlidingSelection } from "../../ui/SlidingSelection"; -import { paginateHistoryRows, type HistoryFilter, type HistoryPage, type HistoryRow, type HistoryViewModel } from "./history-model"; +import { + createHistoryTableColumnWidths, + getHistoryTableGridTemplate, + getHistoryTableMinWidth, + HISTORY_TABLE_COLUMN_IDS, + paginateHistoryRows, + resizeHistoryTableColumn, + type HistoryFilter, + type HistoryPage, + type HistoryRow, + type HistoryTableColumnId, + type HistoryTableColumnWidths, + type HistoryViewModel +} from "./history-model"; import "./history.css"; export interface HistoryViewActions { @@ -29,7 +51,7 @@ export interface HistoryViewProps { actions: HistoryViewActions; } -const filterItems: Array<{ id: HistoryFilter; label: string }> = [ +const filterItems: Array<{ id: HistoryFilter; label: string }> = [ { id: "all", label: "Alle Einträge" }, { id: "today", label: "Heute" }, { id: "week", label: "Letzte 7 Tage" }, @@ -37,11 +59,51 @@ const filterItems: Array<{ id: HistoryFilter; label: string }> = [ { id: "completed", label: "Fertig" }, { id: "deleted", label: "Gelöscht" }, { id: "failed", label: "Fehlgeschlagen" } -]; +]; + +const HISTORY_TABLE_COLUMNS = ["Paket / Datei", "Status", "Größe", "Hoster", "Gestartet", "Beendet"] as const; +const HISTORY_TABLE_COLUMN_STORAGE_KEY = "mdd.history-table-columns.v1"; +let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null; + +function loadHistoryTableColumnWidths(): HistoryTableColumnWidths { + try { + const stored = typeof window === "undefined" ? null : window.localStorage.getItem(HISTORY_TABLE_COLUMN_STORAGE_KEY); + return createHistoryTableColumnWidths(stored ? JSON.parse(stored) : undefined); + } catch { + return createHistoryTableColumnWidths(); + } +} + +function applyHistoryTableColumnWidths(source: HTMLElement, widths: HistoryTableColumnWidths): void { + const table = source.closest(".history-table"); + if (!table) return; + const template = getHistoryTableGridTemplate(widths); + const minWidth = `${getHistoryTableMinWidth(widths)}px`; + table.querySelectorAll(".history-table-header-row, .history-row, .history-detail-row").forEach((row) => { + if (!row.classList.contains("history-detail-row")) { + row.style.gridTemplateColumns = template; + } + row.style.minWidth = minWidth; + }); +} + +function persistHistoryTableColumnWidths(widths: HistoryTableColumnWidths): void { + try { + window.localStorage.setItem(HISTORY_TABLE_COLUMN_STORAGE_KEY, JSON.stringify(widths)); + } catch { + } +} + +function syncHistoryTableScroll(event: UIEvent): void { + const header = event.currentTarget.parentElement?.querySelector(".history-table-header"); + if (header) { + header.scrollLeft = event.currentTarget.scrollLeft; + } +} -function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement { +function HistoryRowDetails({ row, minWidth }: { row: HistoryRow; minWidth: number }): ReactElement { return ( -
+
Provider
{row.providerLabel}
@@ -159,18 +221,47 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all" ? "Noch kein Verlauf" : "Keine passenden Einträge"; - const announcement = model.loading + const announcement = model.loading ? { role: "status" as const, live: "polite" as const, message: "Verlauf wird geladen. Die gespeicherten Einträge werden geladen." } : model.error ? { role: "alert" as const, live: "assertive" as const, message: `${model.error}. Öffne die Ansicht erneut, um es noch einmal zu versuchen.` } - : null; + : null; + const columnWidths = loadHistoryTableColumnWidths(); + const gridTemplateColumns = getHistoryTableGridTemplate(columnWidths); + const minWidth = getHistoryTableMinWidth(columnWidths); + const beginResize = (event: PointerEvent, column: HistoryTableColumnId): void => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture?.(event.pointerId); + historyTableResizeSession = { column, startX: event.clientX, initial: loadHistoryTableColumnWidths() }; + }; + const continueResize = (event: PointerEvent): void => { + const active = historyTableResizeSession; + if (!active) return; + const next = resizeHistoryTableColumn(active.initial, active.column, event.clientX - active.startX); + applyHistoryTableColumnWidths(event.currentTarget, next); + persistHistoryTableColumnWidths(next); + }; + const finishResize = (event: PointerEvent): void => { + if (!historyTableResizeSession) return; + event.currentTarget.releasePointerCapture?.(event.pointerId); + historyTableResizeSession = null; + }; + const resizeWithKeyboard = (event: KeyboardEvent, column: HistoryTableColumnId): void => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + const next = resizeHistoryTableColumn(loadHistoryTableColumnWidths(), column, event.key === "ArrowRight" ? 16 : -16); + applyHistoryTableColumnWidths(event.currentTarget, next); + persistHistoryTableColumnWidths(next); + }; return (

Verlauf

-
+
- Paket / Datei - Status - Größe - Hoster - Gestartet - Beendet + {HISTORY_TABLE_COLUMNS.map((column, index) => ( + + {column} +
- + {model.loading ? ( ) : model.error ? ( @@ -212,7 +315,8 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo className={`history-row${isSelected ? " is-selected" : ""}`} data-history-row-id={row.id} onContextMenu={onContextMenu} - role="row" + role="row" + style={{ gridTemplateColumns, minWidth }} >
- {isExpanded ? : null} + {isExpanded ? : null}
); }) diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index 817497f..81cf4fa 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -49,6 +49,44 @@ export interface HistoryPage { export const HISTORY_PAGE_SIZE = 100; +export const HISTORY_TABLE_COLUMN_IDS = ["name", "status", "size", "hoster", "started", "completed"] as const; +export type HistoryTableColumnId = typeof HISTORY_TABLE_COLUMN_IDS[number]; +export type HistoryTableColumnWidths = Record; + +const HISTORY_TABLE_COLUMN_LIMITS: Record = { + name: { initial: 320, min: 220, max: 680 }, + status: { initial: 150, min: 120, max: 280 }, + size: { initial: 190, min: 140, max: 320 }, + hoster: { initial: 180, min: 120, max: 360 }, + started: { initial: 185, min: 150, max: 280 }, + completed: { initial: 185, min: 150, max: 280 } +}; + +export function createHistoryTableColumnWidths(value?: unknown): HistoryTableColumnWidths { + const raw = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return Object.fromEntries(HISTORY_TABLE_COLUMN_IDS.map((id) => { + const limits = HISTORY_TABLE_COLUMN_LIMITS[id]; + const candidate = typeof raw[id] === "number" && Number.isFinite(raw[id]) ? Math.round(raw[id]) : limits.initial; + return [id, Math.max(limits.min, Math.min(limits.max, candidate))]; + })) as HistoryTableColumnWidths; +} + +export function resizeHistoryTableColumn( + widths: HistoryTableColumnWidths, + column: HistoryTableColumnId, + delta: number +): HistoryTableColumnWidths { + return createHistoryTableColumnWidths({ ...widths, [column]: widths[column] + delta }); +} + +export function getHistoryTableGridTemplate(widths: HistoryTableColumnWidths): string { + return `48px ${HISTORY_TABLE_COLUMN_IDS.map((id) => `${widths[id]}px`).join(" ")} minmax(72px, 1fr)`; +} + +export function getHistoryTableMinWidth(widths: HistoryTableColumnWidths): number { + return 48 + 72 + HISTORY_TABLE_COLUMN_IDS.reduce((sum, id) => sum + widths[id], 0); +} + const providerLabels: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", diff --git a/src/renderer/views/history/history.css b/src/renderer/views/history/history.css index 635319a..41c1762 100644 --- a/src/renderer/views/history/history.css +++ b/src/renderer/views/history/history.css @@ -145,25 +145,21 @@ width: 1px; } -.history-content .history-table { +.history-content .history-table { height: 100%; min-height: 0; - overflow: auto; + overflow: hidden; } .history-table-header-row, .history-row { align-items: center; - display: grid; - grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px; - min-width: 1080px; -} + display: grid; +} .history-table > .history-table-header { height: 41px; - position: sticky; - top: 0; - z-index: 1; + overflow: hidden; } .history-table-header-row { @@ -175,12 +171,21 @@ text-transform: uppercase; } -.history-table-header-row > span { +.history-table-header-row > span, +.history-row > span { + text-align: left; +} + +.history-table-header-row > span:first-child, +.history-table-header-row > span:last-child, +.history-row > span:first-child, +.history-row > span:last-child { text-align: center; } -.history-table-header-row > span:nth-child(2) { - text-align: left; +.history-table-header-row > span:nth-child(4), +.history-row > span:nth-child(4) { + text-align: right; } .history-table-header-row > span, @@ -189,13 +194,57 @@ padding: 0 10px; } -.history-table > .history-table-body { - overflow: visible; -} - -.history-row-group { - min-width: 1080px; -} +.history-table > .history-table-body { + overflow: auto; +} + +.history-row-group { + min-width: 100%; + width: max-content; +} + +.history-resizable-header { + align-items: center; + display: flex; + height: 100%; + position: relative; +} + +.history-table-header-row > .history-resizable-header:nth-child(4) { + justify-content: flex-end; +} + +.history-column-resizer { + background: transparent; + border: 0; + bottom: 7px; + cursor: col-resize; + outline: 0; + padding: 0; + position: absolute; + right: -4px; + top: 7px; + touch-action: none; + width: 9px; + z-index: 2; +} + +.history-column-resizer::after { + background: var(--ui-border); + bottom: 0; + content: ""; + left: 4px; + opacity: 0; + position: absolute; + top: 0; + width: 1px; +} + +.history-column-resizer:hover::after, +.history-column-resizer:focus-visible::after { + background: var(--ui-accent); + opacity: 1; +} .history-row { border-bottom: 1px solid var(--ui-border); @@ -314,10 +363,9 @@ place-items: center; } -.history-detail-row { +.history-detail-row { border-bottom: 1px solid var(--ui-border); background: var(--ui-input); - min-width: 1080px; } .history-detail-cell { @@ -426,27 +474,4 @@ padding: 0 9px; } - .history-table-header-row, - .history-row { - grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px; - min-width: 996px; - } - - .history-row-group, - .history-detail-row { - min-width: 996px; - } -} - -@media (max-width: 1120px) { - .history-table-header-row, - .history-row { - grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px; - min-width: 932px; - } - - .history-row-group, - .history-detail-row { - min-width: 932px; - } -} +} diff --git a/src/renderer/views/statistics/StatisticsView.tsx b/src/renderer/views/statistics/StatisticsView.tsx index c609ce0..50524f6 100644 --- a/src/renderer/views/statistics/StatisticsView.tsx +++ b/src/renderer/views/statistics/StatisticsView.tsx @@ -66,9 +66,15 @@ function providerScopeLabel(scope: StatisticsProviderScope | null): string { if (scope === "current-queue") { return "Aktuelle Queue"; } - if (scope === "today") { - return "Heute"; - } + if (scope === "today") { + return "Heute"; + } + if (scope === "week") { + return "Sieben Tage"; + } + if (scope === "month") { + return "30 Tage"; + } if (scope === "all") { return "Gesamt"; } @@ -79,9 +85,12 @@ function emptyProviderMessage(model: StatisticsViewModel): string { if (model.coverage === "unavailable") { return model.message; } - if (model.providerScope === "today") { - return "Heute wurden noch keine Providerbytes erfasst."; - } + if (model.providerScope === "today") { + return "Heute wurden noch keine Providerbytes erfasst."; + } + if (model.providerScope === "week" || model.providerScope === "month") { + return "In diesem Zeitraum wurden noch keine Providerwerte erfasst."; + } if (model.providerScope === "all") { return "Noch keine gespeicherten Providerbytes vorhanden."; } diff --git a/src/renderer/views/statistics/statistics-model.ts b/src/renderer/views/statistics/statistics-model.ts index 571423c..1185b06 100644 --- a/src/renderer/views/statistics/statistics-model.ts +++ b/src/renderer/views/statistics/statistics-model.ts @@ -1,10 +1,10 @@ -import { getProviderUsageDayKey } from "../../../shared/provider-daily-limits"; -import type { DebridProvider, DownloadItem, DownloadSummary, UiSnapshot } from "../../../shared/types"; +import { aggregateStatisticsRange, type StatisticsAggregate } from "../../../shared/statistics-aggregation"; +import type { DebridProvider, DownloadItem, DownloadSummary, StatisticsProviderBucket, UiSnapshot } from "../../../shared/types"; export type StatisticsRange = "session" | "today" | "week" | "month" | "all"; export type StatisticsCoverage = "partial" | "unavailable"; export type StatisticsSessionState = "empty" | "idle" | "active" | "paused"; -export type StatisticsProviderScope = "current-queue" | "today" | "all"; +export type StatisticsProviderScope = "current-queue" | "today" | "week" | "month" | "all"; export type StatisticsMetricTone = "danger"; export interface StatisticsMetric { @@ -54,8 +54,6 @@ const providerLabels: Record = { linksnappy: "LinkSnappy" }; -const historicalUnavailableMessage = "Für diesen Zeitraum werden noch keine historischen Daten gespeichert."; - function normalizeNonNegative(value: number): number { return Number.isFinite(value) ? Math.max(0, value) : 0; } @@ -153,36 +151,53 @@ function deriveQueueProviders(items: DownloadItem[]): StatisticsProviderRow[] { return sortProviderRows([...providers.values()]); } -function deriveUsageProviders(usage: Partial>): StatisticsProviderRow[] { +function deriveUsageProviders( + usage: Partial>, + outcomes: Partial> = {} +): StatisticsProviderRow[] { const rows: StatisticsProviderRow[] = []; - for (const [id, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) { + const providerIds = new Set([ + ...(Object.keys(usage) as DebridProvider[]), + ...(Object.keys(outcomes) as DebridProvider[]) + ]); + for (const id of providerIds) { + const rawBytes = usage[id]; const bytes = normalizeNonNegative(rawBytes ?? 0); - if (bytes <= 0 || !providerLabels[id]) { + const completed = normalizeCount(outcomes[id]?.completed ?? 0); + const failed = normalizeCount(outcomes[id]?.failed ?? 0); + if ((bytes <= 0 && completed <= 0 && failed <= 0) || !providerLabels[id]) { continue; } rows.push({ id, label: providerLabels[id], bytes, - completed: null, - failed: null + completed, + failed }); } return sortProviderRows(rows); } -function sumProviderBytes(rows: StatisticsProviderRow[]): number { - return rows.reduce((total, row) => total + row.bytes, 0); +function aggregateMetrics(aggregate: StatisticsAggregate, sourceLabel: string): StatisticsMetrics { + return { + downloadedBytes: availableMetric(aggregate.downloadedBytes, sourceLabel), + files: availableMetric(aggregate.completedFiles, sourceLabel), + successRate: successRateMetric(aggregate.completedFiles, aggregate.failedFiles, sourceLabel), + averageSpeedBps: aggregate.averageSpeedBps === null + ? unavailableMetric("Noch keine aktive Downloadzeit mit übertragenen Daten erfasst") + : availableMetric(aggregate.averageSpeedBps, sourceLabel), + errors: availableMetric( + aggregate.failedFiles, + sourceLabel, + aggregate.failedFiles > 0 ? "danger" : undefined + ) + }; } -function buildUnavailableMetrics(): StatisticsMetrics { - return { - downloadedBytes: unavailableMetric(historicalUnavailableMessage), - files: unavailableMetric(historicalUnavailableMessage), - successRate: unavailableMetric(historicalUnavailableMessage), - averageSpeedBps: unavailableMetric(historicalUnavailableMessage), - errors: unavailableMetric(historicalUnavailableMessage) - }; +function recordedDaysMessage(label: string, coveredDays: number): string { + const days = coveredDays === 1 ? "1 erfasster Tag" : `${coveredDays} erfasste Tage`; + return `${label}: ${days} werden bis heute zusammengefasst.`; } export function buildStatisticsViewModel( @@ -192,53 +207,42 @@ export function buildStatisticsViewModel( ): StatisticsViewModel { const sessionState = deriveSessionState(snapshot); - if (range === "week" || range === "month") { - return { - range, - coverage: "unavailable", - message: historicalUnavailableMessage, - sessionState, - metrics: buildUnavailableMetrics(), - providerScope: null, - providers: [], - errorResetAvailable: false - }; - } - - if (range === "today") { - const isCurrentDay = snapshot.settings.providerDailyUsageDay === getProviderUsageDayKey(nowMs); - const providers = isCurrentDay ? deriveUsageProviders(snapshot.settings.providerDailyUsageBytes) : []; + if (range === "today" || range === "week" || range === "month") { + const days = range === "today" ? 1 : range === "week" ? 7 : 30; + const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, days, nowMs); + const label = range === "today" ? "Heute" : range === "week" ? "Letzte sieben Tage" : "Letzte 30 Tage"; return { range, coverage: "partial", - message: "Heutige Daten stammen aus den lokalen Provider-Nutzungszählern des aktuellen Kalendertags.", + message: range === "today" + ? "Heutige Werte stammen aus der lokalen Statistikaufzeichnung." + : recordedDaysMessage(label, aggregate.coveredDays), sessionState, - metrics: { - downloadedBytes: availableMetric(sumProviderBytes(providers), "Provider-Nutzung heute"), - files: unavailableMetric("Dateianzahlen werden nicht tagesweise gespeichert"), - successRate: unavailableMetric("Ergebnisse werden nicht tagesweise gespeichert"), - averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert"), - errors: unavailableMetric("Fehler werden nicht tagesweise gespeichert") - }, - providerScope: "today", - providers, + metrics: aggregateMetrics(aggregate, label), + providerScope: range, + providers: deriveUsageProviders( + Object.fromEntries(Object.entries(aggregate.providers).map(([provider, bucket]) => [provider, bucket?.bytes ?? 0])), + aggregate.providers + ), errorResetAvailable: false }; } if (range === "all") { - const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes); + const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, null, nowMs); + const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes, aggregate.providers); + const recordedMetrics = aggregateMetrics(aggregate, "Seit Beginn der Statistikaufzeichnung"); return { range, coverage: "partial", - message: "Gesamtwerte stammen aus dauerhaft gespeicherten Zählern. Ergebnisse und Geschwindigkeiten werden nicht historisch gespeichert.", + message: "Datenmenge und Dateien stammen aus den Gesamtzählern; Ergebnisse und Durchschnitt seit Beginn der Statistikaufzeichnung.", sessionState, metrics: { downloadedBytes: availableMetric(snapshot.stats.totalDownloadedAllTime, "Gesamtzähler"), files: availableMetric(snapshot.stats.totalFilesAllTime, "Gesamtzähler"), - successRate: unavailableMetric("Ergebnisse werden nicht dauerhaft gespeichert"), - averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert"), - errors: unavailableMetric("Fehler werden nicht dauerhaft gespeichert") + successRate: recordedMetrics.successRate, + averageSpeedBps: recordedMetrics.averageSpeedBps, + errors: recordedMetrics.errors }, providerScope: "all", providers, diff --git a/src/shared/statistics-aggregation.ts b/src/shared/statistics-aggregation.ts new file mode 100644 index 0000000..48be6da --- /dev/null +++ b/src/shared/statistics-aggregation.ts @@ -0,0 +1,66 @@ +import { getProviderUsageDayKey } from "./provider-daily-limits"; +import type { DebridProvider, StatisticsLedger, StatisticsProviderBucket } from "./types"; + +export interface StatisticsAggregate { + downloadedBytes: number; + measuredBytes: number; + completedFiles: number; + failedFiles: number; + activeDownloadMs: number; + averageSpeedBps: number | null; + coveredDays: number; + startedAt: number; + providers: Partial>; +} + +function emptyProviderBucket(): StatisticsProviderBucket { + return { bytes: 0, completed: 0, failed: 0 }; +} + +function localWindowStart(nowMs: number, days: number): string { + const date = new Date(nowMs); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() - Math.max(0, Math.floor(days) - 1)); + return getProviderUsageDayKey(date.getTime()); +} + +export function aggregateStatisticsRange( + ledger: StatisticsLedger | null | undefined, + days: number | null, + nowMs = Date.now() +): StatisticsAggregate { + const endKey = getProviderUsageDayKey(nowMs); + const startKey = days === null ? "0000-00-00" : localWindowStart(nowMs, days); + const aggregate: StatisticsAggregate = { + downloadedBytes: 0, + measuredBytes: 0, + completedFiles: 0, + failedFiles: 0, + activeDownloadMs: 0, + averageSpeedBps: null, + coveredDays: 0, + startedAt: Math.max(0, Number(ledger?.startedAt) || nowMs), + providers: {} + }; + for (const day of ledger?.days ?? []) { + if (day.day < startKey || day.day > endKey) continue; + aggregate.coveredDays += 1; + aggregate.downloadedBytes += day.downloadedBytes; + aggregate.measuredBytes += day.measuredBytes; + aggregate.completedFiles += day.completedFiles; + aggregate.failedFiles += day.failedFiles; + aggregate.activeDownloadMs += day.activeDownloadMs; + for (const [provider, bucket] of Object.entries(day.providers) as Array<[DebridProvider, StatisticsProviderBucket | undefined]>) { + if (!bucket) continue; + const target = aggregate.providers[provider] ?? emptyProviderBucket(); + target.bytes += bucket.bytes; + target.completed += bucket.completed; + target.failed += bucket.failed; + aggregate.providers[provider] = target; + } + } + if (aggregate.activeDownloadMs > 0 && aggregate.measuredBytes > 0) { + aggregate.averageSpeedBps = Math.floor(aggregate.measuredBytes * 1000 / aggregate.activeDownloadMs); + } + return aggregate; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 6202d48..fd3d981 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -33,15 +33,37 @@ export type ExtractCpuPriority = "high" | "middle" | "low"; export type HistoryRetentionMode = "never" | "session" | "permanent"; export type LogStorageLocation = "appdata" | "desktop"; -export interface BandwidthScheduleEntry { +export interface BandwidthScheduleEntry { id: string; startHour: number; endHour: number; speedLimitKbps: number; enabled: boolean; -} - -export interface DownloadStats { +} + +export interface StatisticsProviderBucket { + bytes: number; + completed: number; + failed: number; +} + +export interface StatisticsDayBucket { + day: string; + downloadedBytes: number; + measuredBytes: number; + completedFiles: number; + failedFiles: number; + activeDownloadMs: number; + providers: Partial>; +} + +export interface StatisticsLedger { + version: 1; + startedAt: number; + days: StatisticsDayBucket[]; +} + +export interface DownloadStats { totalDownloaded: number; totalDownloadedAllTime: number; totalFiles?: number; @@ -52,8 +74,9 @@ export interface DownloadStats { appSessionStartedAt: number; sessionRuntimeMs: number; totalRuntimeMs: number; - runtimeMeasuredAt: number; -} + runtimeMeasuredAt: number; + statistics?: StatisticsLedger; +} export interface DebridAccountStatus { accountId: string; diff --git a/tests/backup-payload.test.ts b/tests/backup-payload.test.ts index 2b115cd..aaa0813 100644 --- a/tests/backup-payload.test.ts +++ b/tests/backup-payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload"; -import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types"; +import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../src/shared/types"; function settings(overrides: Partial = {}): AppSettings { return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings; @@ -11,16 +11,18 @@ const session: SessionState = { runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "", paused: false, running: true, updatedAt: 0 }; -const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry]; - -const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history }; +const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry]; +const statistics: StatisticsLedger = { version: 1, startedAt: 1, days: [] }; + +const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics }; describe("buildBackupPayload — default is settings-only", () => { it("omits session AND history when backupIncludeDownloads is false (default)", () => { const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings }); expect(p.kind).toBe("settings-only"); expect(p.session).toBeUndefined(); - expect(p.history).toBeUndefined(); + expect(p.history).toBeUndefined(); + expect(p.statistics).toBeUndefined(); expect(p.settings).toBeDefined(); }); @@ -28,7 +30,8 @@ describe("buildBackupPayload — default is settings-only", () => { const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings }); expect(p.kind).toBe("full"); expect(p.session).toBe(session); - expect(p.history).toBe(history); + expect(p.history).toBe(history); + expect(p.statistics).toBe(statistics); }); it("treats a missing flag as settings-only (safe default)", () => { diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index c653b3e..fd642d4 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -6,11 +6,15 @@ import type { HistoryEntry } from "../src/shared/types"; import { buildHistoryViewModel, deriveHistoryHoster, - deriveHistoryStartAt, - filterHistoryRows, + deriveHistoryStartAt, + filterHistoryRows, + createHistoryTableColumnWidths, + getHistoryTableGridTemplate, + getHistoryTableMinWidth, HISTORY_PAGE_SIZE, paginateHistoryRows, - pruneHistoryIds, + pruneHistoryIds, + resizeHistoryTableColumn, selectVisibleHistoryIds, type HistoryFilter, type HistoryViewEntry @@ -234,6 +238,28 @@ describe("history model", () => { }); describe("HistoryView", () => { + it("uses bounded persistent widths and one exact grid for history headers and rows", () => { + const defaults = createHistoryTableColumnWidths(); + const resized = resizeHistoryTableColumn(defaults, "status", 80); + const clamped = createHistoryTableColumnWidths({ ...defaults, name: -500, completed: 9000 }); + + expect(resized.status).toBe(defaults.status + 80); + expect(clamped.name).toBeGreaterThan(0); + expect(clamped.completed).toBeLessThan(9000); + expect(getHistoryTableGridTemplate(resized)).toContain(`${resized.status}px`); + expect(getHistoryTableMinWidth(resized)).toBeGreaterThan(getHistoryTableMinWidth(defaults)); + + const html = renderToStaticMarkup( + + ); + const template = getHistoryTableGridTemplate(defaults).replaceAll(" ", " "); + expect(html.match(new RegExp(`grid-template-columns:${template.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g"))).toHaveLength(3); + expect(html.match(/Spaltenbreite ändern/g)).toHaveLength(6); + }); + it("builds the complete page status as one localizable text value", () => { expect(historyPageStatusLabel({ page: 2, pageSize: 100, rangeLabel: "101–200 von 250", rows: [], totalItems: 250, totalPages: 3 })) .toBe("Seite 2 von 3"); @@ -320,7 +346,7 @@ describe("HistoryView", () => { expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1); }); - it("keeps the header and rows inside the same internal horizontal scroll context", () => { + it("keeps one clipped header synchronized with the scrollable history rows", () => { const html = renderToStaticMarkup( { expect(tableStart).toBeGreaterThan(-1); expect(headerStart).toBeGreaterThan(tableStart); expect(bodyStart).toBeGreaterThan(headerStart); - expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*auto;/s); - expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;/s); - expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*visible;/s); - expect(css).not.toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s); + expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*hidden;/s); + expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*overflow:\s*hidden;/s); + expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s); }); it("keeps every real AppShell history surface non-selectable while allowing text selection only for detail values", () => { @@ -393,7 +418,7 @@ describe("HistoryView", () => { expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/); }); - it("matches the download action control and centers every header except package and file", () => { + it("matches the download action control and aligns every header with its data column", () => { const model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now); const content = HistoryContentPage({ actions: createActions(), @@ -406,8 +431,9 @@ describe("HistoryView", () => { expect(actionCell.props.children.props.children).toBe("⋮"); expect(styles).toMatch(/\.history-row-action button\s*\{[^}]*background:\s*var\(--ui-input\);[^}]*border:\s*1px solid var\(--ui-border\);[^}]*height:\s*30px;[^}]*width:\s*30px;/s); - expect(styles).toMatch(/\.history-table-header-row > span\s*\{[^}]*text-align:\s*center;/s); - expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(2\)\s*\{[^}]*text-align:\s*left;/s); + expect(styles).toMatch(/\.history-table-header-row > span,\s*\.history-row > span\s*\{[^}]*text-align:\s*left;/s); + expect(styles).toMatch(/\.history-table-header-row > span:nth-child\(4\),\s*\.history-row > span:nth-child\(4\)\s*\{[^}]*text-align:\s*right;/s); + expect(styles).toMatch(/\.history-row-action\s*\{[^}]*place-items:\s*center;/s); }); it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => { diff --git a/tests/statistics-ledger.test.ts b/tests/statistics-ledger.test.ts new file mode 100644 index 0000000..6385576 --- /dev/null +++ b/tests/statistics-ledger.test.ts @@ -0,0 +1,163 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { DownloadManager } from "../src/main/download-manager"; +import { createStoragePaths, emptySession } from "../src/main/storage"; +import { + aggregateStatisticsRange, + createStatisticsLedger, + loadStatisticsLedger, + recordStatisticsActiveInterval, + recordStatisticsBytes, + recordStatisticsOutcome, + saveStatisticsLedger +} from "../src/main/statistics-ledger"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function localTime(day: number, hour = 12): number { + return new Date(2026, 7, day, hour, 0, 0, 0).getTime(); +} + +describe("statistics ledger", () => { + it("aggregates every available day inside a rolling seven-day window without requiring seven complete days", () => { + let ledger = createStatisticsLedger(localTime(10)); + ledger = recordStatisticsBytes(ledger, "realdebrid", 100, localTime(7)); + ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(7)); + ledger = recordStatisticsBytes(ledger, "debridlink", 200, localTime(8)); + ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", localTime(8)); + ledger = recordStatisticsBytes(ledger, "realdebrid", 300, localTime(10)); + ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(10)); + + const aggregate = aggregateStatisticsRange(ledger, 7, localTime(10)); + + expect(aggregate).toMatchObject({ + downloadedBytes: 600, + completedFiles: 2, + failedFiles: 1, + coveredDays: 3 + }); + expect(aggregate.providers).toEqual({ + debridlink: { bytes: 200, completed: 0, failed: 1 }, + realdebrid: { bytes: 400, completed: 2, failed: 0 } + }); + }); + + it("excludes data outside the requested calendar window and computes average speed from measured active time", () => { + let ledger = createStatisticsLedger(localTime(10)); + ledger = recordStatisticsBytes(ledger, "realdebrid", 8_000, localTime(10)); + ledger = recordStatisticsActiveInterval(ledger, localTime(10, 10), localTime(10, 10) + 2_000); + ledger = recordStatisticsBytes(ledger, "debridlink", 99_000, localTime(3)); + ledger = recordStatisticsActiveInterval(ledger, localTime(3, 10), localTime(3, 10) + 1_000); + + const today = aggregateStatisticsRange(ledger, 1, localTime(10)); + const week = aggregateStatisticsRange(ledger, 7, localTime(10)); + const month = aggregateStatisticsRange(ledger, 30, localTime(10)); + + expect(today).toMatchObject({ downloadedBytes: 8_000, activeDownloadMs: 2_000, averageSpeedBps: 4_000 }); + expect(week.downloadedBytes).toBe(8_000); + expect(month).toMatchObject({ downloadedBytes: 107_000, activeDownloadMs: 3_000 }); + }); + + it("splits active intervals across local calendar days", () => { + const start = new Date(2026, 7, 9, 23, 59, 59, 500).getTime(); + const end = new Date(2026, 7, 10, 0, 0, 0, 500).getTime(); + const ledger = recordStatisticsActiveInterval(createStatisticsLedger(start), start, end); + + expect(aggregateStatisticsRange(ledger, 1, localTime(10)).activeDownloadMs).toBe(500); + expect(aggregateStatisticsRange(ledger, 1, localTime(9)).activeDownloadMs).toBe(500); + }); + + it("persists normalized statistics and recovers safely from malformed files", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-ledger-")); + roots.push(root); + const filePath = path.join(root, "rd_statistics.json"); + const ledger = recordStatisticsOutcome( + recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 4_096, localTime(10)), + "realdebrid", + "completed", + localTime(10) + ); + + saveStatisticsLedger(filePath, ledger); + expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger); + + fs.writeFileSync(filePath, "{broken", "utf8"); + expect(loadStatisticsLedger(filePath, localTime(11))).toEqual(createStatisticsLedger(localTime(11))); + }); + + it("retries transient Windows rename failures while preserving the statistics file", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-rename-")); + roots.push(root); + const filePath = path.join(root, "rd_statistics.json"); + const ledger = recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 8_192, localTime(10)); + const rename = fs.renameSync.bind(fs); + let attempts = 0; + const spy = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => { + attempts += 1; + if (attempts < 3) { + throw Object.assign(new Error("busy"), { code: "EPERM" }); + } + return rename(source, target); + }); + + saveStatisticsLedger(filePath, ledger); + + expect(attempts).toBe(3); + expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger); + spy.mockRestore(); + }); + + it("records provider bytes and terminal outcomes through the download manager and restores them after restart", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-manager-")); + roots.push(root); + const paths = createStoragePaths(root); + const session = emptySession(); + session.items.item = { + id: "item", + packageId: "package", + url: "https://example.test/file", + provider: "realdebrid", + providerLabel: "Real-Debrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 4_096, + totalBytes: 4_096, + progressPercent: 100, + fileName: "file.bin", + targetPath: path.join(root, "file.bin"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt: Date.now(), + updatedAt: Date.now() + }; + const manager = new DownloadManager(defaultSettings(), session, paths); + + (manager as any).runItemIds.add("item"); + (manager as any).recordProviderDownloadedBytes("realdebrid", 4_096); + (manager as any).recordRunOutcome("item", "completed"); + manager.persistNowSync(); + + const current = aggregateStatisticsRange(manager.getStats().statistics, 1); + expect(current).toMatchObject({ downloadedBytes: 4_096, completedFiles: 1, failedFiles: 0 }); + expect(current.providers.realdebrid).toEqual({ bytes: 4_096, completed: 1, failed: 0 }); + expect(fs.existsSync(paths.statisticsFile)).toBe(true); + + const restored = new DownloadManager(defaultSettings(), emptySession(), paths); + expect(aggregateStatisticsRange(restored.getStats().statistics, 1)).toMatchObject({ + downloadedBytes: 4_096, + completedFiles: 1 + }); + }); +}); diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 3f5c100..16faf0d 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -5,9 +5,8 @@ import { describe, expect, it } from "vitest"; import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types"; import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App"; import { - buildStatisticsViewModel, - type StatisticsMetric, - type StatisticsRange + buildStatisticsViewModel, + type StatisticsMetric } from "../src/renderer/views/statistics/statistics-model"; import { StatisticsContent, @@ -15,7 +14,13 @@ import { StatisticsView, type StatisticsViewActions } from "../src/renderer/views/statistics/StatisticsView"; -import { createVisualFixture } from "./visual/fixtures"; +import { createVisualFixture } from "./visual/fixtures"; +import { + createStatisticsLedger, + recordStatisticsActiveInterval, + recordStatisticsBytes, + recordStatisticsOutcome +} from "../src/main/statistics-ledger"; const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); @@ -183,23 +188,28 @@ describe("statistics model", () => { expect(model.providers.some((row) => row.id.includes("host"))).toBe(false); }); - it("uses daily provider usage only for the matching local day", () => { - const snapshot = createSnapshot(); - snapshot.stats.totalDownloaded = 999_999; - snapshot.settings.providerDailyUsageDay = "2026-08-10"; - snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 }; + it("uses persisted daily bytes, results and active time for every statistic shown today", () => { + const snapshot = createSnapshot(); + let ledger = createStatisticsLedger(now); + ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now); + ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now); + ledger = recordStatisticsBytes(ledger, "alldebrid", 1_500, now); + ledger = recordStatisticsOutcome(ledger, "alldebrid", "failed", now); + ledger = recordStatisticsActiveInterval(ledger, now - 2_000, now); + snapshot.stats.statistics = ledger; const model = buildStatisticsViewModel(snapshot, "today", now); - expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true }); + expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true }); + expect(model.metrics.files).toMatchObject({ value: 1, available: true }); + expect(model.metrics.successRate).toMatchObject({ value: 50, available: true }); + expect(model.metrics.errors).toMatchObject({ value: 1, available: true }); + expect(model.metrics.averageSpeedBps).toMatchObject({ value: 1_000, available: true }); expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([ ["alldebrid", 1_500], ["realdebrid", 500] ]); - expectUnavailable(model.metrics.files); - expectUnavailable(model.metrics.successRate); - expectUnavailable(model.metrics.errors); - expectUnavailable(model.metrics.averageSpeedBps); + expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [1, 0]]); }); it("treats a stale daily key as a genuine zero today without stale provider rows", () => { @@ -213,28 +223,42 @@ describe("statistics model", () => { expect(model.providers).toEqual([]); }); - it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => { - const snapshot = createSnapshot(); - snapshot.stats.totalDownloaded = 5_000; - snapshot.stats.totalDownloadedAllTime = 50_000; - snapshot.settings.providerDailyUsageDay = "2026-08-10"; - snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 }; - snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 }; - - const model = buildStatisticsViewModel(snapshot, range, now); - - expect(model.coverage).toBe("unavailable"); - expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert."); - expect(model.providers).toEqual([]); - expect(model.providerScope).toBeNull(); - Object.values(model.metrics).forEach(expectUnavailable); - }); - - it("uses all-time counters and provider totals without inventing historical outcomes", () => { + it("sums every available day in seven-day and 30-day windows without waiting for a full period", () => { + const snapshot = createSnapshot(); + let ledger = createStatisticsLedger(new Date(2026, 7, 3, 12).getTime()); + ledger = recordStatisticsBytes(ledger, "realdebrid", 300, new Date(2026, 7, 3, 12).getTime()); + ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", new Date(2026, 7, 3, 12).getTime()); + ledger = recordStatisticsBytes(ledger, "debridlink", 700, new Date(2026, 7, 8, 12).getTime()); + ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", new Date(2026, 7, 8, 12).getTime()); + ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now); + ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now); + snapshot.stats.statistics = ledger; + + const week = buildStatisticsViewModel(snapshot, "week", now); + const month = buildStatisticsViewModel(snapshot, "month", now); + + expect(week.metrics.downloadedBytes.value).toBe(1_200); + expect(week.metrics.files.value).toBe(1); + expect(week.metrics.errors.value).toBe(1); + expect(week.message).toContain("2 erfasste Tage"); + expect(month.metrics.downloadedBytes.value).toBe(1_500); + expect(month.metrics.files.value).toBe(2); + expect(month.metrics.errors.value).toBe(1); + expect(month.message).toContain("3 erfasste Tage"); + }); + + it("combines existing all-time counters with persisted outcomes, provider results and measured average speed", () => { const snapshot = createSnapshot(); snapshot.stats.totalDownloadedAllTime = 25_000; snapshot.stats.totalFilesAllTime = 42; - snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 }; + snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 }; + let ledger = createStatisticsLedger(now - 10_000); + ledger = recordStatisticsBytes(ledger, "realdebrid", 2_000, now); + ledger = recordStatisticsActiveInterval(ledger, now - 2_000, now); + ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now); + ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now); + ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", now); + snapshot.stats.statistics = ledger; snapshot.summary = { total: 10, success: 9, @@ -253,10 +277,11 @@ describe("statistics model", () => { ["debridlink", 20_000], ["realdebrid", 5_000] ]); - expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true); - expectUnavailable(model.metrics.successRate); - expectUnavailable(model.metrics.errors); - expectUnavailable(model.metrics.averageSpeedBps); + expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [2, 0]]); + expect(model.metrics.successRate.available).toBe(true); + expect(model.metrics.successRate.value).toBeCloseTo(200 / 3); + expect(model.metrics.errors).toMatchObject({ value: 1, available: true }); + expect(model.metrics.averageSpeedBps).toMatchObject({ value: 1_000, available: true }); }); it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => { diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 1d61e4e..3af8ac8 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -458,8 +458,38 @@ function createDenseSnapshot(): UiSnapshot { sessionStartedAt: 1786309200000, appSessionStartedAt: 1786309200000, sessionRuntimeMs: 3600000, - totalRuntimeMs: 172800000, - runtimeMeasuredAt: 1786312800000 + totalRuntimeMs: 172800000, + runtimeMeasuredAt: 1786312800000, + statistics: { + version: 1, + startedAt: 1786053600000, + days: [ + { + day: "2026-08-08", + downloadedBytes: 182536110080, + measuredBytes: 182536110080, + completedFiles: 124, + failedFiles: 3, + activeDownloadMs: 21600000, + providers: { + realdebrid: { bytes: 123480309760, completed: 86, failed: 1 }, + debridlink: { bytes: 59055800320, completed: 38, failed: 2 } + } + }, + { + day: "2026-08-10", + downloadedBytes: 541165879488, + measuredBytes: 541165879488, + completedFiles: 310, + failedFiles: 2, + activeDownloadMs: 32400000, + providers: { + realdebrid: { bytes: 328565653504, completed: 192, failed: 1 }, + debridlink: { bytes: 212600225984, completed: 118, failed: 1 } + } + } + ] + } }; snapshot.speedText = "12,0 MB/s"; snapshot.etaText = "00:17:24";