From de154ab783f2df518cf194ee013c5621b39d0a50 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 15 Jun 2026 00:49:19 +0200 Subject: [PATCH] UI Phase 2: Einstellungen-Tab neu strukturiert (Untergruppen + Erklaertexte) + konfigurierbarer Verlauf (Obergrenze + Zeitlimit) Settings-Rework (User-Wunsch, mit Multi-Agent-Design-Runde fuer die Taxonomie: 3 Lenses -> Synthese, gegen das echte settingsDraft-Inventar auf Vollstaendigkeit geprueft, 51 Einstellungen je genau einmal platziert). UI (App.tsx + styles.css): - 5 der 6 Bereiche (Allgemein/Entpacken/Geschwindigkeit/Bereinigung/Updates) in klare Untergruppen mit Unter-Ueberschriften (.settings-subhead) gegliedert, je Bereich ein Intro, und unter JEDER Einstellung ein knapper Erklaertext (.setting-hint, <= ~90 Zeichen, echte Umlaute). Reihenfolge nach Aufgaben-Logik (z.B. Allgemein: Speicherort / Download-Verhalten / Verlauf / Oberflaeche / Discord; Entpacken: Ziel & Ablauf / Deutsche Tonspur / Ablageform / Leistung / Passwoerter). Einige Labels praezisiert (z.B. "Codeberg Repo" -> "Update-Quelle", "Light Mode" -> "Heller Modus"). Account-Bereich bleibt fuer Phase 3 unangetastet. Verlauf-Retention konfigurierbar (vorher: hart 500, kein Zeitlimit): - Neue Settings historyMaxEntries (Standard 500) + historyMaxAgeDays (Standard 0=aus) in types/constants/normalizeSettings (geclamped 50..100000 bzw. 0..3650) + App- Default-Snapshot. Zwei neue Zahlenfelder im Bereich Allgemein -> Verlauf, direkt unter "Verlauf speichern"; ausgegraut wenn nicht "Dauerhaft". - storage.ts: pruneHistoryEntries(entries, limits) wendet Alters- (completedAt < jetzt - Tage) und Anzahl-Grenze an; load/save/addHistoryEntry + die *ForRetention- Wrapper nehmen optionale Limits. app-controller reicht die Limits aus den Settings durch (historyLimits()) und schneidet den Verlauf bei Aenderung der Grenzen aktiv neu (damit "aelter als X Tage" wirklich von der Platte fliegt). 2 neue storage-Tests (Anzahl-Cap, Alters-Pruning). 810 Tests gruen, tsc=6 Baseline, self-check + build ok. --- src/main/app-controller.ts | 16 ++- src/main/constants.ts | 2 + src/main/storage.ts | 45 +++++-- src/renderer/App.tsx | 236 +++++++++++++++++++++++++------------ src/renderer/styles.css | 36 ++++++ src/shared/types.ts | 2 + tests/storage.test.ts | 53 +++++++++ 7 files changed, 296 insertions(+), 94 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index aebe347..b4f8d3e 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -36,7 +36,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; +import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { encryptBackup, decryptBackup } from "./backup-crypto"; @@ -120,7 +120,7 @@ export class AppController { bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), onHistoryEntry: (entry: HistoryEntry) => { - addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry); + addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); } }); this.manager.on("state", (snapshot: UiSnapshot) => { @@ -338,9 +338,13 @@ export class AppController { this.overlayLiveUsageCounters(nextSettings); const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; + const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries + || previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays; this.settings = nextSettings; if (retentionChanged) { resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + } else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") { + saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits()); } saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); @@ -644,7 +648,7 @@ public async checkDebridAccounts(): Promise { appVersion: APP_VERSION, exportedAt: new Date().toISOString(), session: this.manager.getSession(), - history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode) + history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()) }); this.audit("INFO", "Backup exportiert", { kind: payloadObj.kind, @@ -803,8 +807,12 @@ public async checkDebridAccounts(): Promise { logger.info("App beendet"); } + private historyLimits(): { maxEntries: number; maxAgeDays: number } { + return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays }; + } + public getHistory(): HistoryEntry[] { - return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()); } public clearHistory(): void { diff --git a/src/main/constants.ts b/src/main/constants.ts index 1c37493..b0312db 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -101,6 +101,8 @@ export function defaultSettings(): AppSettings { theme: "dark" as const, collapseNewPackages: true, historyRetentionMode: "permanent", + historyMaxEntries: 500, + historyMaxAgeDays: 0, accountListShowDetailedDebridLinkKeys: false, autoSortPackagesByProgress: true, autoSkipExtracted: false, diff --git a/src/main/storage.ts b/src/main/storage.ts index f4e19db..56602f2 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -451,6 +451,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode) ? settings.historyRetentionMode : defaults.historyRetentionMode, + historyMaxEntries: clampNumber(settings.historyMaxEntries, defaults.historyMaxEntries, 50, 100000), + historyMaxAgeDays: clampNumber(settings.historyMaxAgeDays, defaults.historyMaxAgeDays, 0, 3650), accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined ? Boolean(settings.accountListShowDetailedDebridLinkKeys) : defaults.accountListShowDetailedDebridLinkKeys, @@ -1129,6 +1131,23 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat } const MAX_HISTORY_ENTRIES = 500; +const HISTORY_HARD_CAP = 100000; + +export interface HistoryLimits { + maxEntries: number; + maxAgeDays: number; +} + +function pruneHistoryEntries(entries: HistoryEntry[], limits?: HistoryLimits, now = Date.now()): HistoryEntry[] { + const maxEntries = limits && limits.maxEntries > 0 ? Math.min(limits.maxEntries, HISTORY_HARD_CAP) : MAX_HISTORY_ENTRIES; + const maxAgeDays = limits && limits.maxAgeDays > 0 ? limits.maxAgeDays : 0; + let result = entries; + if (maxAgeDays > 0) { + const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000; + result = result.filter((entry) => entry.completedAt >= cutoff); + } + return result.length > maxEntries ? result.slice(0, maxEntries) : result; +} export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null { const entry = asRecord(raw); @@ -1153,7 +1172,7 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry }; } -export function loadHistory(paths: StoragePaths): HistoryEntry[] { +export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] { ensureBaseDir(paths.baseDir); if (!fs.existsSync(paths.historyFile)) { return []; @@ -1164,19 +1183,19 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] { if (!Array.isArray(raw)) return []; const entries: HistoryEntry[] = []; - for (let i = 0; i < raw.length && entries.length < MAX_HISTORY_ENTRIES; i++) { + for (let i = 0; i < raw.length && entries.length < HISTORY_HARD_CAP; i++) { const normalized = normalizeHistoryEntry(raw[i], i); if (normalized) entries.push(normalized); } - return entries; + return pruneHistoryEntries(entries, limits); } catch { return []; } } -export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void { +export function saveHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): void { ensureBaseDir(paths.baseDir); - const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES); + const trimmed = pruneHistoryEntries(entries, limits); const payload = JSON.stringify(trimmed, safeJsonReplacer, 2); const tempPath = `${paths.historyFile}.tmp`; try { @@ -1188,22 +1207,22 @@ export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void } } -export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry): HistoryEntry[] { - const existing = loadHistory(paths); - const updated = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES); - saveHistory(paths, updated); +export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] { + const existing = loadHistory(paths, limits); + const updated = pruneHistoryEntries([entry, ...existing], limits); + saveHistory(paths, updated, limits); return updated; } -export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): HistoryEntry[] { - return retentionMode === "never" ? [] : loadHistory(paths); +export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, limits?: HistoryLimits): HistoryEntry[] { + return retentionMode === "never" ? [] : loadHistory(paths, limits); } -export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry): HistoryEntry[] { +export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] { if (retentionMode === "never") { return []; } - return addHistoryEntry(paths, entry); + return addHistoryEntry(paths, entry, limits); } export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3be61b1..dba2e96 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -852,7 +852,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, + theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, @@ -5077,34 +5077,66 @@ export function App(): ReactElement { {settingsSubTab === "allgemein" && (

Allgemein

+
Grundeinstellungen für Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.
+ +

Speicherort

setText("outputDir", e.target.value)} />
+
Zielordner für alle heruntergeladenen Dateien.
setText("packageName", e.target.value)} /> +
Voreingestellter Name für neue Pakete; leer = automatisch aus den Links.
+ +

Download-Verhalten

-
setNum("maxParallel", Math.max(1, Math.min(50, Number(e.target.value) || 1)))} />
-
setNum("retryLimit", Math.max(0, Math.min(99, Number(e.target.value) || 0)))} />
+
setNum("maxParallel", Math.max(1, Math.min(50, Number(e.target.value) || 1)))} />
Gleichzeitige Downloads (1-50); höher lastet Leitung und Hoster-Slots stärker aus.
+
setNum("retryLimit", Math.max(0, Math.min(99, Number(e.target.value) || 0)))} />
Wiederholungen pro Datei bei Fehlern; 0 = unbegrenzt weiterversuchen.
- - -
- - -
Nie = kein Verlauf. Nur aktuelle Session = wird beim Beenden gelöscht. Dauerhaft = bleibt wie bisher gespeichert.
-
- + +
Unterbrochene Downloads beim Programmstart automatisch fortsetzen.
- - - - +
Kopierte Links werden automatisch erkannt und zur Liste hinzugefügt.
+ +

Verlauf

+ + +
Nie / nur Sitzung / dauerhaft; die Grenzen unten wirken nur bei „Dauerhaft".
+
+
setNum("historyMaxEntries", Math.max(50, Math.min(100000, Number(e.target.value) || 500)))} />
Obergrenze (Standard 500); älteste Einträge fallen darüber hinaus weg.
+
setNum("historyMaxAgeDays", Math.max(0, Math.min(3650, Number(e.target.value) || 0)))} />
Löscht Einträge älter als X Tage; 0 = aus. Nur bei „Dauerhaft" aktiv.
+
+ +

Oberfläche & Bedienung

+ +
Neue Pakete starten zugeklappt; hält lange Listen übersichtlich.
+ +
Laufende Pakete automatisch nach Fortschritt ordnen statt nach Reihenfolge.
+ +
Legt das Fenster in den Tray statt zu beenden; läuft im Hintergrund weiter.
+ +
Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.
+ +
Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.
+ +
Schaltet die Oberfläche von Dunkel auf Hell um.
+ +

Discord-Benachrichtigungen

+
setText("notifyUrl", e.target.value)} />
-
In Discord: Servereinstellungen → Integrationen → Webhooks → Neuer Webhook → URL kopieren und hier eintragen. Die gewählten Ereignisse landen als Nachricht im Kanal.
- +
Discord-Webhook für Meldungen; per „Testen"-Button prüfbar. Leer = aus. In Discord: Servereinstellungen → Integrationen → Webhooks → Neuer Webhook → URL kopieren.
+ setText("notifyMention", e.target.value)} /> -
Wird jeder Nachricht vorangestellt, damit Discord dich pingt. Eigene ID: Discord-Einstellungen → Erweitert → Entwicklermodus an, dann Rechtsklick auf deinen Namen → "User-ID kopieren" und die Zahl hier eintragen.
- - - - +
Wird jeder Meldung vorangestellt (User-ID, @everyone, @here), damit Discord pingt.
+ +
Sendet eine Meldung, sobald ein Paket vollständig geladen wurde.
+ +
Sendet eine Meldung, wenn ein Paket endgültig fehlschlägt.
+ +
Sendet eine Meldung, wenn die ganze Warteschlange abgearbeitet ist.
)} {settingsSubTab === "accounts" && ( @@ -5585,59 +5611,97 @@ export function App(): ReactElement { {settingsSubTab === "entpacken" && (

Entpacken

+
Wann und wie Archive entpackt werden, dazu Tonspur, Ablageform und Leistung.
+ +

Ziel & Ablauf

setText("extractDir", e.target.value)} />
- - - - - -
setBool("autoExtract", e.target.checked)} /> Automatisch entpacken +
Entpackt Archive automatisch, sobald das Paket vollständig geladen ist.
+ +
Überspringt beim Start Archive, deren Inhalt schon vorhanden ist.
+ +
Blendet fertig entpackte Einträge aus der Paketliste aus.
+ +
Entpackt offene Archive auch bei Stopp oder Programmstart ohne laufende Sitzung.
+ +

Deutsche Tonspur

+ +
Reduziert .DL.-Videos auf die deutsche Spur; benötigt ffmpeg.
+ +
- - - -
setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} />
-
- + +
Deutsche Spur per Sprach-Kennung (empfohlen) oder erste Spur; nur aktiv wenn oben an.
+ +

Ablageform

+ +
Benennt kryptische 4sf/4sj-Dateinamen automatisch um (experimentell).
+ +
Legt entpackte Dateien je Paket in einen eigenen Unterordner.
+ +
Verschiebt fertige Videos nach Paketabschluss in einen zentralen Ordner.
setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} />
- +
Zielordner für gesammelte Videos; nur aktiv wenn Sammeln eingeschaltet ist.
+ +

Leistung

+ +
Entpackt schon während des Downloads weiter; spart Zeit, kostet mehr I/O.
+
+
setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} />
Parallele Entpackvorgänge (1-8); höher braucht mehr CPU und Datenträger.
+
CPU-Anteil beim Entpacken: Hoch 80%, Mittel 50%, Niedrig 25%.
+
+ +

Passwörter

+