feat(statistics): persist ranges and align history columns
Persist per-day download volume, outcomes, active transfer time, and provider results so today, seven-day, and 30-day views use real partial-window data. Preserve existing all-time counters while extending totals with newly recorded result metrics. Rebuild the history table around one resizable persisted grid shared by headers and rows, with synchronized overflow and consistent alignment across window sizes.
This commit is contained in:
@@ -71,6 +71,7 @@ import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"
|
|||||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||||
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||||||
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
||||||
|
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
||||||
|
|
||||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||||
@@ -882,7 +883,8 @@ export class AppController {
|
|||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
session: this.manager.getSession(),
|
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
|
remoteDiagnostics
|
||||||
});
|
});
|
||||||
const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase);
|
const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase);
|
||||||
@@ -1001,7 +1003,11 @@ export class AppController {
|
|||||||
const restoredSession = normalizeLoadedSessionTransientFields(
|
const restoredSession = normalizeLoadedSessionTransientFields(
|
||||||
normalizeLoadedSession(parsed.session)
|
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) {
|
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
|
||||||
const normalizedHistory = (parsed.history as unknown[])
|
const normalizedHistory = (parsed.history as unknown[])
|
||||||
|
|||||||
@@ -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";
|
export type BackupKind = "full" | "settings-only";
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@ export interface BackupPayload {
|
|||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
session?: SessionState;
|
session?: SessionState;
|
||||||
history?: HistoryEntry[];
|
history?: HistoryEntry[];
|
||||||
|
statistics?: StatisticsLedger;
|
||||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +26,8 @@ export interface BuildBackupInput {
|
|||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
/** Only bundled when includeDownloads is true. */
|
/** Only bundled when includeDownloads is true. */
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
history: HistoryEntry[];
|
history: HistoryEntry[];
|
||||||
|
statistics?: StatisticsLedger;
|
||||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +48,10 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
|||||||
};
|
};
|
||||||
if (includeDownloads) {
|
if (includeDownloads) {
|
||||||
base.session = input.session;
|
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) {
|
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
||||||
base.remoteDiagnostics = sanitizeBackupRemoteDiagnostics(input.remoteDiagnostics);
|
base.remoteDiagnostics = sanitizeBackupRemoteDiagnostics(input.remoteDiagnostics);
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
PackageEntry,
|
PackageEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
ParsedPackageInput,
|
ParsedPackageInput,
|
||||||
SessionState,
|
SessionState,
|
||||||
StartConflictEntry,
|
StatisticsLedger,
|
||||||
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
@@ -73,6 +74,16 @@ import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize,
|
|||||||
import { mergeKnownTotalBytes } from "./download-size";
|
import { mergeKnownTotalBytes } from "./download-size";
|
||||||
import { DiskCapacityError, DiskReservationCoordinator, type DiskReservationLease } from "./disk-space";
|
import { DiskCapacityError, DiskReservationCoordinator, type DiskReservationLease } from "./disk-space";
|
||||||
import { createRendererState } from "./renderer-state";
|
import { createRendererState } from "./renderer-state";
|
||||||
|
import {
|
||||||
|
addStatisticsActiveIntervalInPlace,
|
||||||
|
addStatisticsBytesInPlace,
|
||||||
|
addStatisticsOutcomeInPlace,
|
||||||
|
createStatisticsLedger,
|
||||||
|
loadStatisticsLedger,
|
||||||
|
normalizeStatisticsLedger,
|
||||||
|
saveStatisticsLedger,
|
||||||
|
seedStatisticsDayProviderBytes
|
||||||
|
} from "./statistics-ledger";
|
||||||
|
|
||||||
type ActiveTask = {
|
type ActiveTask = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -1769,7 +1780,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private statsCache: DownloadStats | null = null;
|
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<typeof createRendererState> | null = null;
|
private settingsSnapshotCache: ReturnType<typeof createRendererState> | null = null;
|
||||||
private settingsSnapshotCacheAt = 0;
|
private settingsSnapshotCacheAt = 0;
|
||||||
@@ -1898,7 +1921,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runtimePersistedAt = startedAt;
|
this.runtimePersistedAt = startedAt;
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.itemCount = Object.keys(this.session.items).length;
|
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);
|
this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber);
|
||||||
if (this.protectAgainstEmptyClobber) {
|
if (this.protectAgainstEmptyClobber) {
|
||||||
logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen");
|
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,
|
sessionStartedAt: this.session.runStartedAt,
|
||||||
appSessionStartedAt: this.appSessionStartedAt,
|
appSessionStartedAt: this.appSessionStartedAt,
|
||||||
sessionRuntimeMs: this.getAppSessionRuntimeMs(now),
|
sessionRuntimeMs: this.getAppSessionRuntimeMs(now),
|
||||||
totalRuntimeMs: this.getLiveTotalRuntimeMs(now),
|
totalRuntimeMs: this.getLiveTotalRuntimeMs(now),
|
||||||
runtimeMeasuredAt: now
|
runtimeMeasuredAt: now,
|
||||||
|
statistics: normalizeStatisticsLedger(this.statisticsLedger, now)
|
||||||
};
|
};
|
||||||
this.statsCache = stats;
|
this.statsCache = stats;
|
||||||
this.statsCacheAt = now;
|
this.statsCacheAt = now;
|
||||||
@@ -2744,11 +2773,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetDownloadStats(): void {
|
public resetDownloadStats(): void {
|
||||||
this.settings.totalDownloadedAllTime = 0;
|
this.settings.totalDownloadedAllTime = 0;
|
||||||
this.settings.totalCompletedFilesAllTime = 0;
|
this.settings.totalCompletedFilesAllTime = 0;
|
||||||
this.settings.providerTotalUsageBytes = {};
|
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();
|
this.lastSettingsPersistAt = nowMs();
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.invalidateStatsCache();
|
this.invalidateStatsCache();
|
||||||
@@ -5992,8 +6025,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public prepareForShutdown(): void {
|
public prepareForShutdown(): void {
|
||||||
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
|
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
|
||||||
|
this.updateStatisticsActivity(nowMs());
|
||||||
this.rotationListenerActive = false;
|
this.rotationListenerActive = false;
|
||||||
this.clearPersistTimer();
|
this.clearPersistTimer();
|
||||||
if (this.stateEmitTimer) {
|
if (this.stateEmitTimer) {
|
||||||
@@ -6070,7 +6104,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!this.guardBlocksSessionSave()) {
|
if (!this.guardBlocksSessionSave()) {
|
||||||
saveSession(this.storagePaths, this.session);
|
saveSession(this.storagePaths, this.session);
|
||||||
}
|
}
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
|
saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
|
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
|
||||||
}
|
}
|
||||||
@@ -6398,11 +6433,22 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!this.guardBlocksSessionSave()) {
|
if (!this.guardBlocksSessionSave()) {
|
||||||
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
|
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.foldRuntimeIntoSettings(now);
|
||||||
this.lastSettingsPersistAt = 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 {
|
public persistNowSync(): void {
|
||||||
@@ -6414,7 +6460,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!this.guardBlocksSessionSave()) {
|
if (!this.guardBlocksSessionSave()) {
|
||||||
saveSession(this.storagePaths, this.session);
|
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 {
|
private emitState(force = false): void {
|
||||||
@@ -6507,8 +6554,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!this.runItemIds.has(itemId)) {
|
if (!this.runItemIds.has(itemId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const previous = this.runOutcomes.get(itemId);
|
const previous = this.runOutcomes.get(itemId);
|
||||||
this.runOutcomes.set(itemId, status);
|
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") {
|
if (status === "completed" && previous !== "completed") {
|
||||||
this.sessionCompletedFiles += 1;
|
this.sessionCompletedFiles += 1;
|
||||||
this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1;
|
this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1;
|
||||||
@@ -8105,7 +8159,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
return;
|
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 nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta);
|
||||||
const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta);
|
const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta);
|
||||||
this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay;
|
this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay;
|
||||||
@@ -8169,6 +8225,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return false;
|
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 {
|
private hasUsableDownloadAccount(): boolean {
|
||||||
return DOWNLOAD_ACCOUNT_PROVIDERS.some((provider) => this.isProviderConfigured(provider));
|
return DOWNLOAD_ACCOUNT_PROVIDERS.some((provider) => this.isProviderConfigured(provider));
|
||||||
}
|
}
|
||||||
@@ -8590,8 +8657,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const myGeneration = this.schedulerGeneration;
|
const myGeneration = this.schedulerGeneration;
|
||||||
logger.info(`Scheduler gestartet (gen=${myGeneration})`);
|
logger.info(`Scheduler gestartet (gen=${myGeneration})`);
|
||||||
try {
|
try {
|
||||||
while (this.session.running && this.schedulerGeneration === myGeneration) {
|
while (this.session.running && this.schedulerGeneration === myGeneration) {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
|
this.updateStatisticsActivity(now);
|
||||||
if (now - this.lastSchedulerHeartbeatAt >= 60000) {
|
if (now - this.lastSchedulerHeartbeatAt >= 60000) {
|
||||||
this.lastSchedulerHeartbeatAt = now;
|
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}`);
|
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) {
|
if (!next) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.startItem(next.packageId, next.itemId);
|
this.startItem(next.packageId, next.itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.runGlobalStallWatchdog(now);
|
this.updateStatisticsActivity(nowMs());
|
||||||
|
|
||||||
|
this.runGlobalStallWatchdog(now);
|
||||||
|
|
||||||
const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false };
|
const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false };
|
||||||
const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed;
|
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 maxParallel = Math.max(1, this.settings.maxParallel);
|
||||||
const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120;
|
const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120;
|
||||||
await sleep(schedulerSleepMs);
|
await sleep(schedulerSleepMs);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.scheduleRunning = false;
|
this.updateStatisticsActivity(nowMs());
|
||||||
|
this.scheduleRunning = false;
|
||||||
logger.info(`Scheduler beendet (gen=${myGeneration})`);
|
logger.info(`Scheduler beendet (gen=${myGeneration})`);
|
||||||
// Stop->Start race: a new run can begin while this loop sleeps (start()'s
|
// Stop->Start race: a new run can begin while this loop sleeps (start()'s
|
||||||
// ensureScheduler early-returns on scheduleRunning, then this loop exits on
|
// ensureScheduler early-returns on scheduleRunning, then this loop exits on
|
||||||
|
|||||||
@@ -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<DebridProvider>([
|
||||||
|
"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<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: 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<Record<DebridProvider, StatisticsProviderBucket>> = {};
|
||||||
|
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<string, StatisticsDayBucket>();
|
||||||
|
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<Record<DebridProvider, number>>,
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -632,7 +632,8 @@ export interface StoragePaths {
|
|||||||
baseDir: string;
|
baseDir: string;
|
||||||
configFile: string;
|
configFile: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
historyFile: string;
|
historyFile: string;
|
||||||
|
statisticsFile: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStoragePaths(baseDir: string): StoragePaths {
|
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||||
@@ -640,7 +641,8 @@ export function createStoragePaths(baseDir: string): StoragePaths {
|
|||||||
baseDir,
|
baseDir,
|
||||||
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
||||||
sessionFile: path.join(baseDir, "rd_session_state.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")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableBody,
|
DataTableBody,
|
||||||
@@ -7,7 +16,20 @@ import {
|
|||||||
} from "../../ui/DataTable";
|
} from "../../ui/DataTable";
|
||||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
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";
|
import "./history.css";
|
||||||
|
|
||||||
export interface HistoryViewActions {
|
export interface HistoryViewActions {
|
||||||
@@ -29,7 +51,7 @@ export interface HistoryViewProps {
|
|||||||
actions: HistoryViewActions;
|
actions: HistoryViewActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
|
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
|
||||||
{ id: "all", label: "Alle Einträge" },
|
{ id: "all", label: "Alle Einträge" },
|
||||||
{ id: "today", label: "Heute" },
|
{ id: "today", label: "Heute" },
|
||||||
{ id: "week", label: "Letzte 7 Tage" },
|
{ id: "week", label: "Letzte 7 Tage" },
|
||||||
@@ -37,11 +59,51 @@ const filterItems: Array<{ id: HistoryFilter; label: string }> = [
|
|||||||
{ id: "completed", label: "Fertig" },
|
{ id: "completed", label: "Fertig" },
|
||||||
{ id: "deleted", label: "Gelöscht" },
|
{ id: "deleted", label: "Gelöscht" },
|
||||||
{ id: "failed", label: "Fehlgeschlagen" }
|
{ 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<HTMLElement>(".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<HTMLDivElement>): void {
|
||||||
|
const header = event.currentTarget.parentElement?.querySelector<HTMLElement>(".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 (
|
return (
|
||||||
<div className="history-detail-row" role="row">
|
<div className="history-detail-row" role="row" style={{ minWidth }}>
|
||||||
<div className="history-detail-cell" role="cell">
|
<div className="history-detail-cell" role="cell">
|
||||||
<dl className="history-details-grid">
|
<dl className="history-details-grid">
|
||||||
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
||||||
@@ -159,18 +221,47 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
|
|||||||
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
||||||
? "Noch kein Verlauf"
|
? "Noch kein Verlauf"
|
||||||
: "Keine passenden Einträge";
|
: "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." }
|
? { role: "status" as const, live: "polite" as const, message: "Verlauf wird geladen. Die gespeicherten Einträge werden geladen." }
|
||||||
: model.error
|
: model.error
|
||||||
? { role: "alert" as const, live: "assertive" as const, message: `${model.error}. Öffne die Ansicht erneut, um es noch einmal zu versuchen.` }
|
? { 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<HTMLButtonElement>, column: HistoryTableColumnId): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
historyTableResizeSession = { column, startX: event.clientX, initial: loadHistoryTableColumnWidths() };
|
||||||
|
};
|
||||||
|
const continueResize = (event: PointerEvent<HTMLButtonElement>): 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<HTMLButtonElement>): void => {
|
||||||
|
if (!historyTableResizeSession) return;
|
||||||
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||||
|
historyTableResizeSession = null;
|
||||||
|
};
|
||||||
|
const resizeWithKeyboard = (event: KeyboardEvent<HTMLButtonElement>, 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 (
|
return (
|
||||||
<section aria-label="Verlaufstabelle" className="history-content">
|
<section aria-label="Verlaufstabelle" className="history-content">
|
||||||
<h1 className="history-main-title">Verlauf</h1>
|
<h1 className="history-main-title">Verlauf</h1>
|
||||||
<DataTable className="history-table" label="Verlauf">
|
<DataTable className="history-table" label="Verlauf">
|
||||||
<DataTableHeader className="history-table-header">
|
<DataTableHeader className="history-table-header">
|
||||||
<div className="history-table-header-row" role="row">
|
<div className="history-table-header-row" role="row" style={{ gridTemplateColumns, minWidth }}>
|
||||||
<span className="history-column-select" role="columnheader">
|
<span className="history-column-select" role="columnheader">
|
||||||
<input
|
<input
|
||||||
aria-label="Alle sichtbaren Einträge auswählen"
|
aria-label="Alle sichtbaren Einträge auswählen"
|
||||||
@@ -180,16 +271,28 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span role="columnheader">Paket / Datei</span>
|
{HISTORY_TABLE_COLUMNS.map((column, index) => (
|
||||||
<span role="columnheader">Status</span>
|
<span className="history-resizable-header" key={column} role="columnheader">
|
||||||
<span role="columnheader">Größe</span>
|
{column}
|
||||||
<span role="columnheader">Hoster</span>
|
<button
|
||||||
<span role="columnheader">Gestartet</span>
|
aria-label={`${column} Spaltenbreite ändern`}
|
||||||
<span role="columnheader">Beendet</span>
|
aria-orientation="vertical"
|
||||||
|
className="history-column-resizer"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => resizeWithKeyboard(event, HISTORY_TABLE_COLUMN_IDS[index])}
|
||||||
|
onPointerCancel={finishResize}
|
||||||
|
onPointerDown={(event) => beginResize(event, HISTORY_TABLE_COLUMN_IDS[index])}
|
||||||
|
onPointerMove={continueResize}
|
||||||
|
onPointerUp={finishResize}
|
||||||
|
role="separator"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
<span role="columnheader">Aktion</span>
|
<span role="columnheader">Aktion</span>
|
||||||
</div>
|
</div>
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
|
<DataTableBody className="history-table-body" data-visual-region="history-table-body" onScroll={syncHistoryTableScroll}>
|
||||||
{model.loading ? (
|
{model.loading ? (
|
||||||
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
|
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
|
||||||
) : model.error ? (
|
) : model.error ? (
|
||||||
@@ -212,7 +315,8 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
|
|||||||
className={`history-row${isSelected ? " is-selected" : ""}`}
|
className={`history-row${isSelected ? " is-selected" : ""}`}
|
||||||
data-history-row-id={row.id}
|
data-history-row-id={row.id}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
role="row"
|
role="row"
|
||||||
|
style={{ gridTemplateColumns, minWidth }}
|
||||||
>
|
>
|
||||||
<span className="history-column-select" role="cell">
|
<span className="history-column-select" role="cell">
|
||||||
<input
|
<input
|
||||||
@@ -252,7 +356,7 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
|
|||||||
>⋮</button>
|
>⋮</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? <HistoryRowDetails row={row} /> : null}
|
{isExpanded ? <HistoryRowDetails minWidth={minWidth} row={row} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,6 +49,44 @@ export interface HistoryPage {
|
|||||||
|
|
||||||
export const HISTORY_PAGE_SIZE = 100;
|
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<HistoryTableColumnId, number>;
|
||||||
|
|
||||||
|
const HISTORY_TABLE_COLUMN_LIMITS: Record<HistoryTableColumnId, { initial: number; min: number; max: number }> = {
|
||||||
|
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<string, unknown> : {};
|
||||||
|
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<DebridProvider, string> = {
|
const providerLabels: Record<DebridProvider, string> = {
|
||||||
realdebrid: "Real-Debrid",
|
realdebrid: "Real-Debrid",
|
||||||
megadebrid: "Mega-Debrid",
|
megadebrid: "Mega-Debrid",
|
||||||
|
|||||||
@@ -145,25 +145,21 @@
|
|||||||
width: 1px;
|
width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-content .history-table {
|
.history-content .history-table {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-header-row,
|
.history-table-header-row,
|
||||||
.history-row {
|
.history-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table > .history-table-header {
|
.history-table > .history-table-header {
|
||||||
height: 41px;
|
height: 41px;
|
||||||
position: sticky;
|
overflow: hidden;
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-header-row {
|
.history-table-header-row {
|
||||||
@@ -175,12 +171,21 @@
|
|||||||
text-transform: uppercase;
|
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;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-header-row > span:nth-child(2) {
|
.history-table-header-row > span:nth-child(4),
|
||||||
text-align: left;
|
.history-row > span:nth-child(4) {
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-header-row > span,
|
.history-table-header-row > span,
|
||||||
@@ -189,13 +194,57 @@
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table > .history-table-body {
|
.history-table > .history-table-body {
|
||||||
overflow: visible;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row-group {
|
.history-row-group {
|
||||||
min-width: 1080px;
|
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 {
|
.history-row {
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
@@ -314,10 +363,9 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-detail-row {
|
.history-detail-row {
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
min-width: 1080px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-detail-cell {
|
.history-detail-cell {
|
||||||
@@ -426,27 +474,4 @@
|
|||||||
padding: 0 9px;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,9 +66,15 @@ function providerScopeLabel(scope: StatisticsProviderScope | null): string {
|
|||||||
if (scope === "current-queue") {
|
if (scope === "current-queue") {
|
||||||
return "Aktuelle Queue";
|
return "Aktuelle Queue";
|
||||||
}
|
}
|
||||||
if (scope === "today") {
|
if (scope === "today") {
|
||||||
return "Heute";
|
return "Heute";
|
||||||
}
|
}
|
||||||
|
if (scope === "week") {
|
||||||
|
return "Sieben Tage";
|
||||||
|
}
|
||||||
|
if (scope === "month") {
|
||||||
|
return "30 Tage";
|
||||||
|
}
|
||||||
if (scope === "all") {
|
if (scope === "all") {
|
||||||
return "Gesamt";
|
return "Gesamt";
|
||||||
}
|
}
|
||||||
@@ -79,9 +85,12 @@ function emptyProviderMessage(model: StatisticsViewModel): string {
|
|||||||
if (model.coverage === "unavailable") {
|
if (model.coverage === "unavailable") {
|
||||||
return model.message;
|
return model.message;
|
||||||
}
|
}
|
||||||
if (model.providerScope === "today") {
|
if (model.providerScope === "today") {
|
||||||
return "Heute wurden noch keine Providerbytes erfasst.";
|
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") {
|
if (model.providerScope === "all") {
|
||||||
return "Noch keine gespeicherten Providerbytes vorhanden.";
|
return "Noch keine gespeicherten Providerbytes vorhanden.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { getProviderUsageDayKey } from "../../../shared/provider-daily-limits";
|
import { aggregateStatisticsRange, type StatisticsAggregate } from "../../../shared/statistics-aggregation";
|
||||||
import type { DebridProvider, DownloadItem, DownloadSummary, UiSnapshot } from "../../../shared/types";
|
import type { DebridProvider, DownloadItem, DownloadSummary, StatisticsProviderBucket, UiSnapshot } from "../../../shared/types";
|
||||||
|
|
||||||
export type StatisticsRange = "session" | "today" | "week" | "month" | "all";
|
export type StatisticsRange = "session" | "today" | "week" | "month" | "all";
|
||||||
export type StatisticsCoverage = "partial" | "unavailable";
|
export type StatisticsCoverage = "partial" | "unavailable";
|
||||||
export type StatisticsSessionState = "empty" | "idle" | "active" | "paused";
|
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 type StatisticsMetricTone = "danger";
|
||||||
|
|
||||||
export interface StatisticsMetric {
|
export interface StatisticsMetric {
|
||||||
@@ -54,8 +54,6 @@ const providerLabels: Record<DebridProvider, string> = {
|
|||||||
linksnappy: "LinkSnappy"
|
linksnappy: "LinkSnappy"
|
||||||
};
|
};
|
||||||
|
|
||||||
const historicalUnavailableMessage = "Für diesen Zeitraum werden noch keine historischen Daten gespeichert.";
|
|
||||||
|
|
||||||
function normalizeNonNegative(value: number): number {
|
function normalizeNonNegative(value: number): number {
|
||||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
}
|
}
|
||||||
@@ -153,36 +151,53 @@ function deriveQueueProviders(items: DownloadItem[]): StatisticsProviderRow[] {
|
|||||||
return sortProviderRows([...providers.values()]);
|
return sortProviderRows([...providers.values()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveUsageProviders(usage: Partial<Record<DebridProvider, number>>): StatisticsProviderRow[] {
|
function deriveUsageProviders(
|
||||||
|
usage: Partial<Record<DebridProvider, number>>,
|
||||||
|
outcomes: Partial<Record<DebridProvider, StatisticsProviderBucket>> = {}
|
||||||
|
): StatisticsProviderRow[] {
|
||||||
const rows: StatisticsProviderRow[] = [];
|
const rows: StatisticsProviderRow[] = [];
|
||||||
for (const [id, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) {
|
const providerIds = new Set<DebridProvider>([
|
||||||
|
...(Object.keys(usage) as DebridProvider[]),
|
||||||
|
...(Object.keys(outcomes) as DebridProvider[])
|
||||||
|
]);
|
||||||
|
for (const id of providerIds) {
|
||||||
|
const rawBytes = usage[id];
|
||||||
const bytes = normalizeNonNegative(rawBytes ?? 0);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
rows.push({
|
rows.push({
|
||||||
id,
|
id,
|
||||||
label: providerLabels[id],
|
label: providerLabels[id],
|
||||||
bytes,
|
bytes,
|
||||||
completed: null,
|
completed,
|
||||||
failed: null
|
failed
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return sortProviderRows(rows);
|
return sortProviderRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sumProviderBytes(rows: StatisticsProviderRow[]): number {
|
function aggregateMetrics(aggregate: StatisticsAggregate, sourceLabel: string): StatisticsMetrics {
|
||||||
return rows.reduce((total, row) => total + row.bytes, 0);
|
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 {
|
function recordedDaysMessage(label: string, coveredDays: number): string {
|
||||||
return {
|
const days = coveredDays === 1 ? "1 erfasster Tag" : `${coveredDays} erfasste Tage`;
|
||||||
downloadedBytes: unavailableMetric(historicalUnavailableMessage),
|
return `${label}: ${days} werden bis heute zusammengefasst.`;
|
||||||
files: unavailableMetric(historicalUnavailableMessage),
|
|
||||||
successRate: unavailableMetric(historicalUnavailableMessage),
|
|
||||||
averageSpeedBps: unavailableMetric(historicalUnavailableMessage),
|
|
||||||
errors: unavailableMetric(historicalUnavailableMessage)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStatisticsViewModel(
|
export function buildStatisticsViewModel(
|
||||||
@@ -192,53 +207,42 @@ export function buildStatisticsViewModel(
|
|||||||
): StatisticsViewModel {
|
): StatisticsViewModel {
|
||||||
const sessionState = deriveSessionState(snapshot);
|
const sessionState = deriveSessionState(snapshot);
|
||||||
|
|
||||||
if (range === "week" || range === "month") {
|
if (range === "today" || range === "week" || range === "month") {
|
||||||
return {
|
const days = range === "today" ? 1 : range === "week" ? 7 : 30;
|
||||||
range,
|
const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, days, nowMs);
|
||||||
coverage: "unavailable",
|
const label = range === "today" ? "Heute" : range === "week" ? "Letzte sieben Tage" : "Letzte 30 Tage";
|
||||||
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) : [];
|
|
||||||
return {
|
return {
|
||||||
range,
|
range,
|
||||||
coverage: "partial",
|
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,
|
sessionState,
|
||||||
metrics: {
|
metrics: aggregateMetrics(aggregate, label),
|
||||||
downloadedBytes: availableMetric(sumProviderBytes(providers), "Provider-Nutzung heute"),
|
providerScope: range,
|
||||||
files: unavailableMetric("Dateianzahlen werden nicht tagesweise gespeichert"),
|
providers: deriveUsageProviders(
|
||||||
successRate: unavailableMetric("Ergebnisse werden nicht tagesweise gespeichert"),
|
Object.fromEntries(Object.entries(aggregate.providers).map(([provider, bucket]) => [provider, bucket?.bytes ?? 0])),
|
||||||
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert"),
|
aggregate.providers
|
||||||
errors: unavailableMetric("Fehler werden nicht tagesweise gespeichert")
|
),
|
||||||
},
|
|
||||||
providerScope: "today",
|
|
||||||
providers,
|
|
||||||
errorResetAvailable: false
|
errorResetAvailable: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (range === "all") {
|
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 {
|
return {
|
||||||
range,
|
range,
|
||||||
coverage: "partial",
|
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,
|
sessionState,
|
||||||
metrics: {
|
metrics: {
|
||||||
downloadedBytes: availableMetric(snapshot.stats.totalDownloadedAllTime, "Gesamtzähler"),
|
downloadedBytes: availableMetric(snapshot.stats.totalDownloadedAllTime, "Gesamtzähler"),
|
||||||
files: availableMetric(snapshot.stats.totalFilesAllTime, "Gesamtzähler"),
|
files: availableMetric(snapshot.stats.totalFilesAllTime, "Gesamtzähler"),
|
||||||
successRate: unavailableMetric("Ergebnisse werden nicht dauerhaft gespeichert"),
|
successRate: recordedMetrics.successRate,
|
||||||
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert"),
|
averageSpeedBps: recordedMetrics.averageSpeedBps,
|
||||||
errors: unavailableMetric("Fehler werden nicht dauerhaft gespeichert")
|
errors: recordedMetrics.errors
|
||||||
},
|
},
|
||||||
providerScope: "all",
|
providerScope: "all",
|
||||||
providers,
|
providers,
|
||||||
|
|||||||
@@ -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<Record<DebridProvider, StatisticsProviderBucket>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+29
-6
@@ -33,15 +33,37 @@ export type ExtractCpuPriority = "high" | "middle" | "low";
|
|||||||
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
||||||
export type LogStorageLocation = "appdata" | "desktop";
|
export type LogStorageLocation = "appdata" | "desktop";
|
||||||
|
|
||||||
export interface BandwidthScheduleEntry {
|
export interface BandwidthScheduleEntry {
|
||||||
id: string;
|
id: string;
|
||||||
startHour: number;
|
startHour: number;
|
||||||
endHour: number;
|
endHour: number;
|
||||||
speedLimitKbps: number;
|
speedLimitKbps: number;
|
||||||
enabled: boolean;
|
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<Record<DebridProvider, StatisticsProviderBucket>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatisticsLedger {
|
||||||
|
version: 1;
|
||||||
|
startedAt: number;
|
||||||
|
days: StatisticsDayBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadStats {
|
||||||
totalDownloaded: number;
|
totalDownloaded: number;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalFiles?: number;
|
totalFiles?: number;
|
||||||
@@ -52,8 +74,9 @@ export interface DownloadStats {
|
|||||||
appSessionStartedAt: number;
|
appSessionStartedAt: number;
|
||||||
sessionRuntimeMs: number;
|
sessionRuntimeMs: number;
|
||||||
totalRuntimeMs: number;
|
totalRuntimeMs: number;
|
||||||
runtimeMeasuredAt: number;
|
runtimeMeasuredAt: number;
|
||||||
}
|
statistics?: StatisticsLedger;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DebridAccountStatus {
|
export interface DebridAccountStatus {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
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> = {}): AppSettings {
|
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
||||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as 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,
|
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
||||||
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
||||||
};
|
};
|
||||||
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
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 };
|
|
||||||
|
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
|
||||||
|
|
||||||
describe("buildBackupPayload — default is settings-only", () => {
|
describe("buildBackupPayload — default is settings-only", () => {
|
||||||
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||||
expect(p.kind).toBe("settings-only");
|
expect(p.kind).toBe("settings-only");
|
||||||
expect(p.session).toBeUndefined();
|
expect(p.session).toBeUndefined();
|
||||||
expect(p.history).toBeUndefined();
|
expect(p.history).toBeUndefined();
|
||||||
|
expect(p.statistics).toBeUndefined();
|
||||||
expect(p.settings).toBeDefined();
|
expect(p.settings).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +30,8 @@ describe("buildBackupPayload — default is settings-only", () => {
|
|||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||||
expect(p.kind).toBe("full");
|
expect(p.kind).toBe("full");
|
||||||
expect(p.session).toBe(session);
|
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)", () => {
|
it("treats a missing flag as settings-only (safe default)", () => {
|
||||||
|
|||||||
+37
-11
@@ -6,11 +6,15 @@ import type { HistoryEntry } from "../src/shared/types";
|
|||||||
import {
|
import {
|
||||||
buildHistoryViewModel,
|
buildHistoryViewModel,
|
||||||
deriveHistoryHoster,
|
deriveHistoryHoster,
|
||||||
deriveHistoryStartAt,
|
deriveHistoryStartAt,
|
||||||
filterHistoryRows,
|
filterHistoryRows,
|
||||||
|
createHistoryTableColumnWidths,
|
||||||
|
getHistoryTableGridTemplate,
|
||||||
|
getHistoryTableMinWidth,
|
||||||
HISTORY_PAGE_SIZE,
|
HISTORY_PAGE_SIZE,
|
||||||
paginateHistoryRows,
|
paginateHistoryRows,
|
||||||
pruneHistoryIds,
|
pruneHistoryIds,
|
||||||
|
resizeHistoryTableColumn,
|
||||||
selectVisibleHistoryIds,
|
selectVisibleHistoryIds,
|
||||||
type HistoryFilter,
|
type HistoryFilter,
|
||||||
type HistoryViewEntry
|
type HistoryViewEntry
|
||||||
@@ -234,6 +238,28 @@ describe("history model", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HistoryView", () => {
|
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(
|
||||||
|
<HistoryView
|
||||||
|
actions={createActions()}
|
||||||
|
model={buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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", () => {
|
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 }))
|
expect(historyPageStatusLabel({ page: 2, pageSize: 100, rangeLabel: "101–200 von 250", rows: [], totalItems: 250, totalPages: 3 }))
|
||||||
.toBe("Seite 2 von 3");
|
.toBe("Seite 2 von 3");
|
||||||
@@ -320,7 +346,7 @@ describe("HistoryView", () => {
|
|||||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
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(
|
const html = renderToStaticMarkup(
|
||||||
<HistoryView
|
<HistoryView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
@@ -335,10 +361,9 @@ describe("HistoryView", () => {
|
|||||||
expect(tableStart).toBeGreaterThan(-1);
|
expect(tableStart).toBeGreaterThan(-1);
|
||||||
expect(headerStart).toBeGreaterThan(tableStart);
|
expect(headerStart).toBeGreaterThan(tableStart);
|
||||||
expect(bodyStart).toBeGreaterThan(headerStart);
|
expect(bodyStart).toBeGreaterThan(headerStart);
|
||||||
expect(css).toMatch(/\.history-content \.history-table\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*\{[^}]*position:\s*sticky;[^}]*top:\s*0;/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*visible;/s);
|
expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s);
|
||||||
expect(css).not.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", () => {
|
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/);
|
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 model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now);
|
||||||
const content = HistoryContentPage({
|
const content = HistoryContentPage({
|
||||||
actions: createActions(),
|
actions: createActions(),
|
||||||
@@ -406,8 +431,9 @@ describe("HistoryView", () => {
|
|||||||
|
|
||||||
expect(actionCell.props.children.props.children).toBe("⋮");
|
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-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,\s*\.history-row > span\s*\{[^}]*text-align:\s*left;/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: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", () => {
|
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
||||||
import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App";
|
import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App";
|
||||||
import {
|
import {
|
||||||
buildStatisticsViewModel,
|
buildStatisticsViewModel,
|
||||||
type StatisticsMetric,
|
type StatisticsMetric
|
||||||
type StatisticsRange
|
|
||||||
} from "../src/renderer/views/statistics/statistics-model";
|
} from "../src/renderer/views/statistics/statistics-model";
|
||||||
import {
|
import {
|
||||||
StatisticsContent,
|
StatisticsContent,
|
||||||
@@ -15,7 +14,13 @@ import {
|
|||||||
StatisticsView,
|
StatisticsView,
|
||||||
type StatisticsViewActions
|
type StatisticsViewActions
|
||||||
} from "../src/renderer/views/statistics/StatisticsView";
|
} 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();
|
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);
|
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses daily provider usage only for the matching local day", () => {
|
it("uses persisted daily bytes, results and active time for every statistic shown today", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloaded = 999_999;
|
let ledger = createStatisticsLedger(now);
|
||||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
|
||||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
|
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);
|
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([
|
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
|
||||||
["alldebrid", 1_500],
|
["alldebrid", 1_500],
|
||||||
["realdebrid", 500]
|
["realdebrid", 500]
|
||||||
]);
|
]);
|
||||||
expectUnavailable(model.metrics.files);
|
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [1, 0]]);
|
||||||
expectUnavailable(model.metrics.successRate);
|
|
||||||
expectUnavailable(model.metrics.errors);
|
|
||||||
expectUnavailable(model.metrics.averageSpeedBps);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
|
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([]);
|
expect(model.providers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
|
it("sums every available day in seven-day and 30-day windows without waiting for a full period", () => {
|
||||||
const snapshot = createSnapshot();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloaded = 5_000;
|
let ledger = createStatisticsLedger(new Date(2026, 7, 3, 12).getTime());
|
||||||
snapshot.stats.totalDownloadedAllTime = 50_000;
|
ledger = recordStatisticsBytes(ledger, "realdebrid", 300, new Date(2026, 7, 3, 12).getTime());
|
||||||
snapshot.settings.providerDailyUsageDay = "2026-08-10";
|
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", new Date(2026, 7, 3, 12).getTime());
|
||||||
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
|
ledger = recordStatisticsBytes(ledger, "debridlink", 700, new Date(2026, 7, 8, 12).getTime());
|
||||||
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
|
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", new Date(2026, 7, 8, 12).getTime());
|
||||||
|
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
|
||||||
const model = buildStatisticsViewModel(snapshot, range, now);
|
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
|
||||||
|
snapshot.stats.statistics = ledger;
|
||||||
expect(model.coverage).toBe("unavailable");
|
|
||||||
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
|
const week = buildStatisticsViewModel(snapshot, "week", now);
|
||||||
expect(model.providers).toEqual([]);
|
const month = buildStatisticsViewModel(snapshot, "month", now);
|
||||||
expect(model.providerScope).toBeNull();
|
|
||||||
Object.values(model.metrics).forEach(expectUnavailable);
|
expect(week.metrics.downloadedBytes.value).toBe(1_200);
|
||||||
});
|
expect(week.metrics.files.value).toBe(1);
|
||||||
|
expect(week.metrics.errors.value).toBe(1);
|
||||||
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
|
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();
|
const snapshot = createSnapshot();
|
||||||
snapshot.stats.totalDownloadedAllTime = 25_000;
|
snapshot.stats.totalDownloadedAllTime = 25_000;
|
||||||
snapshot.stats.totalFilesAllTime = 42;
|
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 = {
|
snapshot.summary = {
|
||||||
total: 10,
|
total: 10,
|
||||||
success: 9,
|
success: 9,
|
||||||
@@ -253,10 +277,11 @@ describe("statistics model", () => {
|
|||||||
["debridlink", 20_000],
|
["debridlink", 20_000],
|
||||||
["realdebrid", 5_000]
|
["realdebrid", 5_000]
|
||||||
]);
|
]);
|
||||||
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
|
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [2, 0]]);
|
||||||
expectUnavailable(model.metrics.successRate);
|
expect(model.metrics.successRate.available).toBe(true);
|
||||||
expectUnavailable(model.metrics.errors);
|
expect(model.metrics.successRate.value).toBeCloseTo(200 / 3);
|
||||||
expectUnavailable(model.metrics.averageSpeedBps);
|
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", () => {
|
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
|
||||||
|
|||||||
@@ -458,8 +458,38 @@ function createDenseSnapshot(): UiSnapshot {
|
|||||||
sessionStartedAt: 1786309200000,
|
sessionStartedAt: 1786309200000,
|
||||||
appSessionStartedAt: 1786309200000,
|
appSessionStartedAt: 1786309200000,
|
||||||
sessionRuntimeMs: 3600000,
|
sessionRuntimeMs: 3600000,
|
||||||
totalRuntimeMs: 172800000,
|
totalRuntimeMs: 172800000,
|
||||||
runtimeMeasuredAt: 1786312800000
|
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.speedText = "12,0 MB/s";
|
||||||
snapshot.etaText = "00:17:24";
|
snapshot.etaText = "00:17:24";
|
||||||
|
|||||||
Reference in New Issue
Block a user