diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 57203b1..8124409 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -79,12 +79,13 @@ import { mergeKnownTotalBytes } from "./download-size"; import { DiskCapacityError, DiskReservationCoordinator, type DiskReservationLease } from "./disk-space"; import { createRendererState } from "./renderer-state"; import { + RollingAccountStatisticsAccumulator, addStatisticsActiveIntervalInPlace, addStatisticsBytesInPlace, addStatisticsOutcomeInPlace, createStatisticsLedger, loadStatisticsLedger, - normalizeStatisticsLedger, + projectStatisticsLedger, saveStatisticsLedger, seedStatisticsDayProviderBytes } from "./statistics-ledger"; @@ -1831,6 +1832,8 @@ export class DownloadManager extends EventEmitter { private statisticsLedger: StatisticsLedger; + private rollingAccountStatistics: RollingAccountStatisticsAccumulator; + private statisticsDirty = false; private statisticsUrgent = false; @@ -1974,7 +1977,8 @@ export class DownloadManager extends EventEmitter { settings.providerDailyUsageBytes, startedAt ); - this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber); + this.rollingAccountStatistics = new RollingAccountStatisticsAccumulator(this.statisticsLedger, 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"); } @@ -2640,7 +2644,8 @@ export class DownloadManager extends EventEmitter { sessionRuntimeMs: this.getAppSessionRuntimeMs(now), totalRuntimeMs: this.getLiveTotalRuntimeMs(now), runtimeMeasuredAt: now, - statistics: normalizeStatisticsLedger(this.statisticsLedger, now) + statistics: projectStatisticsLedger(this.statisticsLedger, now), + rolling24Hours: this.rollingAccountStatistics.snapshot(now) }; this.statsCache = stats; this.statsCacheAt = now; @@ -2842,9 +2847,10 @@ export class DownloadManager extends EventEmitter { public resetDownloadStats(): void { this.settings.totalDownloadedAllTime = 0; this.settings.totalCompletedFilesAllTime = 0; - this.settings.providerTotalUsageBytes = {}; + this.settings.providerTotalUsageBytes = {}; this.settings.debridLinkApiKeyTotalUsageBytes = {}; this.statisticsLedger = createStatisticsLedger(); + this.rollingAccountStatistics.reset(this.statisticsLedger); this.statisticsDirty = false; this.statisticsUrgent = false; saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger); @@ -8217,12 +8223,25 @@ export class DownloadManager extends EventEmitter { } } - private recordProviderDownloadedBytes(provider: DownloadItem["provider"], byteDelta: number, providerAccountId?: string): void { - if (!provider) { - return; - } + private recordProviderDownloadedBytes( + provider: DownloadItem["provider"], + byteDelta: number, + providerAccountId?: string, + providerAccountLabel?: string + ): void { + if (!provider) { + return; + } + const recordedAt = nowMs(); const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; - addStatisticsBytesInPlace(this.statisticsLedger, effectiveProvider, byteDelta); + addStatisticsBytesInPlace(this.statisticsLedger, effectiveProvider, byteDelta, recordedAt); + this.rollingAccountStatistics.record( + effectiveProvider, + byteDelta, + providerAccountId, + providerAccountLabel, + recordedAt + ); this.statisticsDirty = true; const nextUsage = addProviderDailyUsageBytes(this.settings, effectiveProvider, byteDelta); const nextTotalUsage = addProviderTotalUsageBytes(this.settings, effectiveProvider, byteDelta); @@ -10851,7 +10870,12 @@ export class DownloadManager extends EventEmitter { this.session.totalDownloadedBytes += buffer.length; this.sessionDownloadedBytes += buffer.length; this.settings.totalDownloadedAllTime += buffer.length; - this.recordProviderDownloadedBytes(item.provider, buffer.length, item.providerAccountId); + this.recordProviderDownloadedBytes( + item.provider, + buffer.length, + item.providerAccountId, + item.providerAccountLabel + ); this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length); this.recordSpeed(buffer.length, item.packageId); throughputWindowBytes += buffer.length; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index d6b4b7d..f4115c4 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -14,7 +14,8 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; -import { createStoragePaths, emptySession } from "../src/main/storage"; +import { createStoragePaths, emptySession } from "../src/main/storage"; +import { loadStatisticsLedger } from "../src/main/statistics-ledger"; import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; @@ -13352,6 +13353,68 @@ describe("download manager", () => { expect(internal.settings.realDebridAccountTotalUsageBytes).toEqual({ rda_one: 1000, rda_two: 2050 }); }); + it("projects rolling traffic for each concrete account without exposing minute buckets", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + providerDailyUsageDay: getProviderUsageDayKey(), + realDebridApiTokens: serializeRealDebridApiAccounts([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]) + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + const internal = manager as unknown as { + recordProviderDownloadedBytes: ( + provider: "realdebrid", + bytes: number, + providerAccountId?: string, + providerAccountLabel?: string + ) => void; + statisticsLedger: { minutes: unknown[] }; + }; + + internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_one", "Primary"); + internal.recordProviderDownloadedBytes("realdebrid", 75, "rda_two", "Secondary"); + const stats = manager.getStats(); + + expect(stats.rolling24Hours).toMatchObject({ downloadedBytes: 125 }); + expect(stats.rolling24Hours?.accounts.map((account) => [account.id, account.label, account.bytes])).toEqual([ + ["rda_two", "Secondary", 75], + ["rda_one", "Primary", 50] + ]); + expect(stats.statistics?.minutes).toEqual([]); + expect(internal.statisticsLedger.minutes).toHaveLength(1); + }); + + it("keeps rolling traffic on session reset and clears it on total reset", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + providerDailyUsageDay: getProviderUsageDayKey(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }]) + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + const internal = manager as unknown as { + recordProviderDownloadedBytes: ( + provider: "realdebrid", + bytes: number, + providerAccountId?: string, + providerAccountLabel?: string + ) => void; + }; + + internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_one", "Primary"); + manager.resetSessionStats(); + expect(manager.getStats().rolling24Hours?.downloadedBytes).toBe(50); + + manager.resetDownloadStats(); + expect(manager.getStats().rolling24Hours).toMatchObject({ downloadedBytes: 0, accounts: [] }); + expect(loadStatisticsLedger(createStoragePaths(path.join(root, "state")).statisticsFile).minutes).toEqual([]); + }); + it("does not recreate account usage when the source account was removed during the download", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);