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 { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||||
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
||||
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@@ -882,7 +883,8 @@ export class AppController {
|
||||
appVersion: APP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
session: this.manager.getSession(),
|
||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||||
statistics: this.manager.getStats().statistics!,
|
||||
remoteDiagnostics
|
||||
});
|
||||
const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase);
|
||||
@@ -1001,7 +1003,11 @@ export class AppController {
|
||||
const restoredSession = normalizeLoadedSessionTransientFields(
|
||||
normalizeLoadedSession(parsed.session)
|
||||
);
|
||||
saveSession(this.storagePaths, restoredSession);
|
||||
saveSession(this.storagePaths, restoredSession);
|
||||
|
||||
if (parsed.statistics) {
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, normalizeStatisticsLedger(parsed.statistics));
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
|
||||
const normalizedHistory = (parsed.history as unknown[])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
||||
import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../shared/types";
|
||||
|
||||
export type BackupKind = "full" | "settings-only";
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface BackupPayload {
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
session?: SessionState;
|
||||
history?: HistoryEntry[];
|
||||
history?: HistoryEntry[];
|
||||
statistics?: StatisticsLedger;
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
}
|
||||
|
||||
@@ -25,7 +26,8 @@ export interface BuildBackupInput {
|
||||
exportedAt: string;
|
||||
/** Only bundled when includeDownloads is true. */
|
||||
session: SessionState;
|
||||
history: HistoryEntry[];
|
||||
history: HistoryEntry[];
|
||||
statistics?: StatisticsLedger;
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
}
|
||||
|
||||
@@ -46,7 +48,10 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||||
};
|
||||
if (includeDownloads) {
|
||||
base.session = input.session;
|
||||
base.history = input.history;
|
||||
base.history = input.history;
|
||||
if (input.statistics) {
|
||||
base.statistics = input.statistics;
|
||||
}
|
||||
}
|
||||
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
||||
base.remoteDiagnostics = sanitizeBackupRemoteDiagnostics(input.remoteDiagnostics);
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
PackageEntry,
|
||||
PackagePriority,
|
||||
ParsedPackageInput,
|
||||
SessionState,
|
||||
StartConflictEntry,
|
||||
SessionState,
|
||||
StatisticsLedger,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
@@ -73,6 +74,16 @@ import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize,
|
||||
import { mergeKnownTotalBytes } from "./download-size";
|
||||
import { DiskCapacityError, DiskReservationCoordinator, type DiskReservationLease } from "./disk-space";
|
||||
import { createRendererState } from "./renderer-state";
|
||||
import {
|
||||
addStatisticsActiveIntervalInPlace,
|
||||
addStatisticsBytesInPlace,
|
||||
addStatisticsOutcomeInPlace,
|
||||
createStatisticsLedger,
|
||||
loadStatisticsLedger,
|
||||
normalizeStatisticsLedger,
|
||||
saveStatisticsLedger,
|
||||
seedStatisticsDayProviderBytes
|
||||
} from "./statistics-ledger";
|
||||
|
||||
type ActiveTask = {
|
||||
itemId: string;
|
||||
@@ -1769,7 +1780,19 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private statsCache: DownloadStats | null = null;
|
||||
|
||||
private statsCacheAt = 0;
|
||||
private statsCacheAt = 0;
|
||||
|
||||
private statisticsLedger: StatisticsLedger;
|
||||
|
||||
private statisticsDirty = false;
|
||||
|
||||
private statisticsUrgent = false;
|
||||
|
||||
private lastStatisticsPersistAt = 0;
|
||||
|
||||
private statisticsActivityAt = 0;
|
||||
|
||||
private statisticsActivityWasActive = false;
|
||||
|
||||
private settingsSnapshotCache: ReturnType<typeof createRendererState> | null = null;
|
||||
private settingsSnapshotCacheAt = 0;
|
||||
@@ -1898,7 +1921,12 @@ export class DownloadManager extends EventEmitter {
|
||||
this.runtimePersistedAt = startedAt;
|
||||
this.session = session;
|
||||
this.itemCount = Object.keys(this.session.items).length;
|
||||
this.storagePaths = storagePaths;
|
||||
this.storagePaths = storagePaths;
|
||||
this.statisticsLedger = seedStatisticsDayProviderBytes(
|
||||
loadStatisticsLedger(storagePaths.statisticsFile, startedAt),
|
||||
settings.providerDailyUsageBytes,
|
||||
startedAt
|
||||
);
|
||||
this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber);
|
||||
if (this.protectAgainstEmptyClobber) {
|
||||
logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen");
|
||||
@@ -2544,8 +2572,9 @@ export class DownloadManager extends EventEmitter {
|
||||
sessionStartedAt: this.session.runStartedAt,
|
||||
appSessionStartedAt: this.appSessionStartedAt,
|
||||
sessionRuntimeMs: this.getAppSessionRuntimeMs(now),
|
||||
totalRuntimeMs: this.getLiveTotalRuntimeMs(now),
|
||||
runtimeMeasuredAt: now
|
||||
totalRuntimeMs: this.getLiveTotalRuntimeMs(now),
|
||||
runtimeMeasuredAt: now,
|
||||
statistics: normalizeStatisticsLedger(this.statisticsLedger, now)
|
||||
};
|
||||
this.statsCache = stats;
|
||||
this.statsCacheAt = now;
|
||||
@@ -2744,11 +2773,15 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public resetDownloadStats(): void {
|
||||
public resetDownloadStats(): void {
|
||||
this.settings.totalDownloadedAllTime = 0;
|
||||
this.settings.totalCompletedFilesAllTime = 0;
|
||||
this.settings.providerTotalUsageBytes = {};
|
||||
this.settings.debridLinkApiKeyTotalUsageBytes = {};
|
||||
this.settings.debridLinkApiKeyTotalUsageBytes = {};
|
||||
this.statisticsLedger = createStatisticsLedger();
|
||||
this.statisticsDirty = false;
|
||||
this.statisticsUrgent = false;
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger);
|
||||
this.lastSettingsPersistAt = nowMs();
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.invalidateStatsCache();
|
||||
@@ -5992,8 +6025,9 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public prepareForShutdown(): void {
|
||||
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
|
||||
public prepareForShutdown(): void {
|
||||
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
|
||||
this.updateStatisticsActivity(nowMs());
|
||||
this.rotationListenerActive = false;
|
||||
this.clearPersistTimer();
|
||||
if (this.stateEmitTimer) {
|
||||
@@ -6070,7 +6104,8 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!this.guardBlocksSessionSave()) {
|
||||
saveSession(this.storagePaths, this.session);
|
||||
}
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger);
|
||||
} else {
|
||||
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
|
||||
}
|
||||
@@ -6398,11 +6433,22 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!this.guardBlocksSessionSave()) {
|
||||
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
|
||||
}
|
||||
if (now - this.lastSettingsPersistAt >= 30000) {
|
||||
if (now - this.lastSettingsPersistAt >= 30000) {
|
||||
this.foldRuntimeIntoSettings(now);
|
||||
this.lastSettingsPersistAt = now;
|
||||
void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`));
|
||||
}
|
||||
void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`));
|
||||
}
|
||||
if (this.statisticsDirty && (this.statisticsUrgent || now - this.lastStatisticsPersistAt >= 10000)) {
|
||||
this.lastStatisticsPersistAt = now;
|
||||
this.statisticsDirty = false;
|
||||
this.statisticsUrgent = false;
|
||||
try {
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger);
|
||||
} catch (error) {
|
||||
this.statisticsDirty = true;
|
||||
logger.warn(`Statistik konnte nicht gespeichert werden: ${compactErrorText(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public persistNowSync(): void {
|
||||
@@ -6414,7 +6460,8 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!this.guardBlocksSessionSave()) {
|
||||
saveSession(this.storagePaths, this.session);
|
||||
}
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger);
|
||||
}
|
||||
|
||||
private emitState(force = false): void {
|
||||
@@ -6507,8 +6554,15 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!this.runItemIds.has(itemId)) {
|
||||
return;
|
||||
}
|
||||
const previous = this.runOutcomes.get(itemId);
|
||||
this.runOutcomes.set(itemId, status);
|
||||
const previous = this.runOutcomes.get(itemId);
|
||||
this.runOutcomes.set(itemId, status);
|
||||
const item = this.session.items[itemId];
|
||||
const provider = item?.provider ? resolveMegaDebridProvider(this.settings, item.provider) : null;
|
||||
if ((status === "completed" || status === "failed") && previous !== status) {
|
||||
addStatisticsOutcomeInPlace(this.statisticsLedger, provider, status);
|
||||
this.statisticsDirty = true;
|
||||
this.statisticsUrgent = true;
|
||||
}
|
||||
if (status === "completed" && previous !== "completed") {
|
||||
this.sessionCompletedFiles += 1;
|
||||
this.settings.totalCompletedFilesAllTime = Math.max(0, Number(this.settings.totalCompletedFilesAllTime || 0)) + 1;
|
||||
@@ -8105,7 +8159,9 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
||||
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
||||
addStatisticsBytesInPlace(this.statisticsLedger, effectiveProvider, byteDelta);
|
||||
this.statisticsDirty = true;
|
||||
const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta);
|
||||
const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta);
|
||||
this.settings.providerDailyUsageDay = nextUsage.providerDailyUsageDay;
|
||||
@@ -8169,6 +8225,17 @@ export class DownloadManager extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
private updateStatisticsActivity(now: number): void {
|
||||
if (this.statisticsActivityAt > 0 && this.statisticsActivityWasActive && now > this.statisticsActivityAt) {
|
||||
addStatisticsActiveIntervalInPlace(this.statisticsLedger, this.statisticsActivityAt, now);
|
||||
this.statisticsDirty = true;
|
||||
}
|
||||
this.statisticsActivityAt = now;
|
||||
this.statisticsActivityWasActive = this.session.running
|
||||
&& !this.session.paused
|
||||
&& [...this.activeTasks.values()].some((task) => this.session.items[task.itemId]?.status === "downloading");
|
||||
}
|
||||
|
||||
private hasUsableDownloadAccount(): boolean {
|
||||
return DOWNLOAD_ACCOUNT_PROVIDERS.some((provider) => this.isProviderConfigured(provider));
|
||||
}
|
||||
@@ -8590,8 +8657,9 @@ export class DownloadManager extends EventEmitter {
|
||||
const myGeneration = this.schedulerGeneration;
|
||||
logger.info(`Scheduler gestartet (gen=${myGeneration})`);
|
||||
try {
|
||||
while (this.session.running && this.schedulerGeneration === myGeneration) {
|
||||
const now = nowMs();
|
||||
while (this.session.running && this.schedulerGeneration === myGeneration) {
|
||||
const now = nowMs();
|
||||
this.updateStatisticsActivity(now);
|
||||
if (now - this.lastSchedulerHeartbeatAt >= 60000) {
|
||||
this.lastSchedulerHeartbeatAt = now;
|
||||
logger.info(`Scheduler Heartbeat: active=${this.activeTasks.size}, queued=${this.countQueuedItems()}, reconnect=${this.reconnectActive()}, paused=${this.session.paused}, postProcess=${this.packagePostProcessTasks.size}`);
|
||||
@@ -8624,10 +8692,12 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
this.startItem(next.packageId, next.itemId);
|
||||
}
|
||||
|
||||
this.runGlobalStallWatchdog(now);
|
||||
this.startItem(next.packageId, next.itemId);
|
||||
}
|
||||
|
||||
this.updateStatisticsActivity(nowMs());
|
||||
|
||||
this.runGlobalStallWatchdog(now);
|
||||
|
||||
const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false };
|
||||
const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed;
|
||||
@@ -8640,9 +8710,10 @@ export class DownloadManager extends EventEmitter {
|
||||
const maxParallel = Math.max(1, this.settings.maxParallel);
|
||||
const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120;
|
||||
await sleep(schedulerSleepMs);
|
||||
}
|
||||
} finally {
|
||||
this.scheduleRunning = false;
|
||||
}
|
||||
} finally {
|
||||
this.updateStatisticsActivity(nowMs());
|
||||
this.scheduleRunning = false;
|
||||
logger.info(`Scheduler beendet (gen=${myGeneration})`);
|
||||
// Stop->Start race: a new run can begin while this loop sleeps (start()'s
|
||||
// ensureScheduler early-returns on scheduleRunning, then this loop exits on
|
||||
|
||||
@@ -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;
|
||||
configFile: string;
|
||||
sessionFile: string;
|
||||
historyFile: string;
|
||||
historyFile: string;
|
||||
statisticsFile: string;
|
||||
}
|
||||
|
||||
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
@@ -640,7 +641,8 @@ export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
baseDir,
|
||||
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
||||
sessionFile: path.join(baseDir, "rd_session_state.json"),
|
||||
historyFile: path.join(baseDir, "rd_history.json")
|
||||
historyFile: path.join(baseDir, "rd_history.json"),
|
||||
statisticsFile: path.join(baseDir, "rd_statistics.json")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user