diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index c144012..33f56b9 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -76,6 +76,7 @@ export class AppController { private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; private autoResumePending = false; + private runtimeStatsTimer: NodeJS.Timeout | null = null; public constructor() { configureLogger(this.storagePaths.baseDir); @@ -114,6 +115,11 @@ export class AppController { runtimeDir: this.storagePaths.baseDir }); startDebugServer(this.manager, this.storagePaths.baseDir); + this.runtimeStatsTimer = setInterval(() => { + this.manager.persistRuntimeStats(); + this.settings = this.manager.getSettings(); + }, 60_000); + this.runtimeStatsTimer.unref?.(); if (this.settings.autoResumeOnStart) { const snapshot = this.manager.getSnapshot(); @@ -237,6 +243,7 @@ export class AppController { const liveSettings = this.manager.getSettings(); nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); + nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs()); nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay; nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) }; nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) }; @@ -639,6 +646,10 @@ export class AppController { } public shutdown(): void { + if (this.runtimeStatsTimer) { + clearInterval(this.runtimeStatsTimer); + this.runtimeStatsTimer = null; + } stopDebugServer(); abortActiveUpdateDownload(); this.manager.prepareForShutdown(); diff --git a/src/main/constants.ts b/src/main/constants.ts index 055595a..d8ee91c 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -104,6 +104,7 @@ export function defaultSettings(): AppSettings { confirmDeleteSelection: true, totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, + totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], extractCpuPriority: "high", diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index dc32bc2..0ccab85 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -681,7 +681,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi ...buildStatsPayload(snapshot), allTime: { totalDownloadedAllTime: settings.totalDownloadedAllTime, - totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs } }); return; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 92370df..74c5b21 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1254,6 +1254,9 @@ export class DownloadManager extends EventEmitter { private lastPersistAt = 0; private lastSettingsPersistAt = 0; + private appSessionStartedAt = 0; + private runtimePersistedTotalMs = 0; + private runtimePersistedAt = 0; private cleanupQueue: Promise = Promise.resolve(); @@ -1332,6 +1335,10 @@ export class DownloadManager extends EventEmitter { public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); this.settings = settings; + const startedAt = nowMs(); + this.appSessionStartedAt = startedAt; + this.runtimePersistedTotalMs = Math.max(0, Number(settings.totalRuntimeAllTimeMs || 0)); + this.runtimePersistedAt = startedAt; this.session = cloneSession(session); this.itemCount = Object.keys(this.session.items).length; this.storagePaths = storagePaths; @@ -1600,7 +1607,11 @@ export class DownloadManager extends EventEmitter { const previous = this.settings; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); + const now = nowMs(); + next.totalRuntimeAllTimeMs = Math.max(next.totalRuntimeAllTimeMs || 0, this.getLiveTotalRuntimeMs(now)); this.settings = next; + this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0; + this.runtimePersistedAt = now; this.ensureProviderDailyUsageFresh(nowMs()); this.debridService.setSettings(next); this.allDebridHostInfoCache.clear(); @@ -1749,13 +1760,53 @@ export class DownloadManager extends EventEmitter { totalFilesSession: this.sessionCompletedFiles, totalFilesAllTime: this.settings.totalCompletedFilesAllTime, totalPackages: this.session.packageOrder.length, - sessionStartedAt: this.session.runStartedAt + sessionStartedAt: this.session.runStartedAt, + appSessionStartedAt: this.appSessionStartedAt, + sessionRuntimeMs: this.getAppSessionRuntimeMs(now), + totalRuntimeMs: this.getLiveTotalRuntimeMs(now), + runtimeMeasuredAt: now }; this.statsCache = stats; this.statsCacheAt = now; return stats; } + public getLiveTotalRuntimeMs(now = nowMs()): number { + return Math.max(0, this.runtimePersistedTotalMs + Math.max(0, now - this.runtimePersistedAt)); + } + + private getAppSessionRuntimeMs(now = nowMs()): number { + return this.appSessionStartedAt > 0 ? Math.max(0, now - this.appSessionStartedAt) : 0; + } + + private foldRuntimeIntoSettings(now = nowMs()): boolean { + const totalRuntimeMs = this.getLiveTotalRuntimeMs(now); + if (!Number.isFinite(totalRuntimeMs) || totalRuntimeMs <= (this.settings.totalRuntimeAllTimeMs || 0)) { + return false; + } + this.settings.totalRuntimeAllTimeMs = totalRuntimeMs; + this.runtimePersistedTotalMs = totalRuntimeMs; + this.runtimePersistedAt = now; + this.invalidateStatsCache(); + return true; + } + + public persistRuntimeStats(sync = false): void { + if (this.blockAllPersistence) { + return; + } + const now = nowMs(); + if (!this.foldRuntimeIntoSettings(now)) { + return; + } + this.lastSettingsPersistAt = now; + if (sync) { + saveSettings(this.storagePaths, this.settings); + return; + } + void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`)); + } + private invalidateStatsCache(): void { this.statsCache = null; this.statsCacheAt = 0; @@ -4293,6 +4344,7 @@ export class DownloadManager extends EventEmitter { const pkgCount = Object.keys(this.session.packages).length; const itemCount = Object.keys(this.session.items).length; logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`); + this.foldRuntimeIntoSettings(nowMs()); saveSession(this.storagePaths, this.session); saveSettings(this.storagePaths, this.settings); } else { @@ -4614,6 +4666,7 @@ export class DownloadManager extends EventEmitter { this.lastPersistAt = now; void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); 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)}`)); } diff --git a/src/main/storage.ts b/src/main/storage.ts index bf86e24..37fc10b 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -384,6 +384,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs, theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules), columnOrder: normalizeColumnOrder(settings.columnOrder), diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 01a4671..971ca15 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -89,7 +89,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B ...buildStatsPayload(snapshot), allTime: { totalDownloadedAllTime: settings.totalDownloadedAllTime, - totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs } }); addJson(zip, "overview/debug-setup.json", debugSetup); diff --git a/src/main/support-data.ts b/src/main/support-data.ts index cf5f32d..3c46d37 100644 --- a/src/main/support-data.ts +++ b/src/main/support-data.ts @@ -134,6 +134,7 @@ export function buildRedactedSettingsPayload(settings: AppSettings): Record ({ totalFilesSession: 0, totalFilesAllTime: 0, totalPackages: 0, - sessionStartedAt: 0 + sessionStartedAt: 0, + appSessionStartedAt: 0, + sessionRuntimeMs: 0, + totalRuntimeMs: 0, + runtimeMeasuredAt: 0 }); const emptySnapshot = (): UiSnapshot => ({ @@ -783,7 +787,7 @@ const emptySnapshot = (): UiSnapshot => ({ updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", collapseNewPackages: true, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true, accountListShowDetailedDebridLinkKeys: false, - bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, + bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], autoExtractWhenStopped: true, disabledProviders: [], @@ -928,6 +932,55 @@ function humanSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`; } +function formatRuntimeDuration(durationMs: number): string { + const totalSeconds = Math.max(0, Math.floor((durationMs || 0) / 1000)); + const minuteSeconds = 60; + const hourSeconds = 60 * minuteSeconds; + const daySeconds = 24 * hourSeconds; + const weekSeconds = 7 * daySeconds; + const monthSeconds = 30 * daySeconds; + + const formatUnit = (value: number, singular: string, plural: string, padTo = 0): string => { + const normalized = Math.max(0, Math.floor(value)); + const text = padTo > 0 ? String(normalized).padStart(padTo, "0") : String(normalized); + return `${text} ${normalized === 1 ? singular : plural}`; + }; + + if (totalSeconds < hourSeconds) { + const minutes = Math.floor(totalSeconds / minuteSeconds); + const seconds = totalSeconds % minuteSeconds; + return `${formatUnit(minutes, "Minute", "Minuten")}, ${formatUnit(seconds, "Sekunde", "Sekunden", 2)}`; + } + + if (totalSeconds < daySeconds) { + const hours = Math.floor(totalSeconds / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten", 2)}`; + } + + if (totalSeconds < weekSeconds) { + const days = Math.floor(totalSeconds / daySeconds); + const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; + } + + if (totalSeconds < monthSeconds) { + const weeks = Math.floor(totalSeconds / weekSeconds); + const days = Math.floor((totalSeconds % weekSeconds) / daySeconds); + const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; + } + + const months = Math.floor(totalSeconds / monthSeconds); + const weeks = Math.floor((totalSeconds % monthSeconds) / weekSeconds); + const days = Math.floor((totalSeconds % weekSeconds) / daySeconds); + const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); + const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); + return `${formatUnit(months, "Monat", "Monate")}, ${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; +} + function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string { return source === "web" ? "Web-Login" : "API-Key"; } @@ -1317,6 +1370,7 @@ export function App(): ReactElement { const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); const [scheduleTimeInput, setScheduleTimeInput] = useState(""); const [scheduleCountdown, setScheduleCountdown] = useState(""); + const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); const settingsDirtyRef = useRef(false); const settingsDraftRevisionRef = useRef(0); const panelDirtyRevisionRef = useRef(0); @@ -1535,6 +1589,11 @@ export function App(): ReactElement { return () => clearInterval(timer); }, [snapshot.settings.scheduledStartEpochMs]); + useEffect(() => { + const timer = setInterval(() => setRuntimeNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + const showToast = useCallback((message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } @@ -3716,6 +3775,12 @@ export function App(): ReactElement { return Object.entries(stats); }, [snapshot.session.items]); + const runtimeOffsetMs = snapshot.stats.runtimeMeasuredAt > 0 + ? Math.max(0, runtimeNow - snapshot.stats.runtimeMeasuredAt) + : 0; + const liveSessionRuntimeMs = Math.max(0, (snapshot.stats.sessionRuntimeMs || 0) + runtimeOffsetMs); + const liveTotalRuntimeMs = Math.max(0, (snapshot.stats.totalRuntimeMs || 0) + runtimeOffsetMs); + return (
Heruntergeladen (Gesamt) {humanSize(snapshot.stats.totalDownloadedAllTime)}
+
+ Laufzeit (Session) + {formatRuntimeDuration(liveSessionRuntimeMs)} +
+
+ Laufzeit (Gesamt) + {formatRuntimeDuration(liveTotalRuntimeMs)} +
Fertige Dateien (Gesamt) {snapshot.stats.totalFilesAllTime} diff --git a/src/shared/types.ts b/src/shared/types.ts index c3d87e6..7eaffc3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -45,6 +45,10 @@ export interface DownloadStats { totalFilesAllTime: number; totalPackages: number; sessionStartedAt: number; + appSessionStartedAt: number; + sessionRuntimeMs: number; + totalRuntimeMs: number; + runtimeMeasuredAt: number; } export interface AppSettings { @@ -110,6 +114,7 @@ export interface AppSettings { confirmDeleteSelection: boolean; totalDownloadedAllTime: number; totalCompletedFilesAllTime: number; + totalRuntimeAllTimeMs: number; bandwidthSchedules: BandwidthScheduleEntry[]; columnOrder: string[]; extractCpuPriority: ExtractCpuPriority; diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 17486b3..d9be324 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -173,7 +173,11 @@ function buildSnapshot(baseDir: string): UiSnapshot { totalFilesSession: 0, totalFilesAllTime: 0, totalPackages: 1, - sessionStartedAt: Date.now() - 30_000 + sessionStartedAt: Date.now() - 30_000, + appSessionStartedAt: Date.now() - 60_000, + sessionRuntimeMs: 60_000, + totalRuntimeMs: 3 * 60_000, + runtimeMeasuredAt: Date.now() }, speedText: "8.0 MB/s", etaText: "ETA: 00:25", @@ -209,7 +213,8 @@ async function createFixture() { debridLinkApiKeys, debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [], totalDownloadedAllTime: 128 * 1024 * 1024, - totalCompletedFilesAllTime: 12 + totalCompletedFilesAllTime: 12, + totalRuntimeAllTimeMs: 5 * 60_000 }); saveHistory(storagePaths, [ { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index d7def29..60340b0 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6709,6 +6709,36 @@ describe("download manager", () => { expect(fs.existsSync(originalExtractedPath)).toBe(false); }); + it("tracks app runtime for session and all-time statistics", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const stateDir = path.join(root, "state"); + const storagePaths = createStoragePaths(stateDir); + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + totalRuntimeAllTimeMs: 2 * 60 * 60 * 1000 + }, + emptySession(), + storagePaths + ); + + await new Promise((resolve) => setTimeout(resolve, 120)); + + const stats = manager.getStats(); + expect(stats.sessionRuntimeMs).toBeGreaterThanOrEqual(100); + expect(stats.totalRuntimeMs).toBeGreaterThanOrEqual(2 * 60 * 60 * 1000 + 100); + expect(stats.runtimeMeasuredAt).toBeGreaterThan(0); + + manager.persistRuntimeStats(true); + const savedSettings = JSON.parse(fs.readFileSync(storagePaths.configFile, "utf8")) as { totalRuntimeAllTimeMs?: number }; + expect(savedSettings.totalRuntimeAllTimeMs || 0).toBeGreaterThanOrEqual(2 * 60 * 60 * 1000 + 100); + }, 10000); + it("writes auto-rename details into rename and item logs", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/link-export.test.ts b/tests/link-export.test.ts index f7400e3..92a13b4 100644 --- a/tests/link-export.test.ts +++ b/tests/link-export.test.ts @@ -115,7 +115,11 @@ function buildSnapshot(): UiSnapshot { totalFilesSession: 0, totalFilesAllTime: 0, totalPackages: 2, - sessionStartedAt: 0 + sessionStartedAt: 0, + appSessionStartedAt: 0, + sessionRuntimeMs: 0, + totalRuntimeMs: 0, + runtimeMeasuredAt: 0 }, speedText: "", etaText: "",