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.
This commit is contained in:
Sucukdeluxe 2026-06-15 00:49:19 +02:00
parent 3a37c5a537
commit de154ab783
7 changed files with 296 additions and 94 deletions

View File

@ -36,7 +36,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback"; 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 { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
@ -120,7 +120,7 @@ export class AppController {
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
onHistoryEntry: (entry: HistoryEntry) => { 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) => { this.manager.on("state", (snapshot: UiSnapshot) => {
@ -338,9 +338,13 @@ export class AppController {
this.overlayLiveUsageCounters(nextSettings); this.overlayLiveUsageCounters(nextSettings);
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries
|| previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays;
this.settings = nextSettings; this.settings = nextSettings;
if (retentionChanged) { if (retentionChanged) {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); 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); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
@ -644,7 +648,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
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) history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits())
}); });
this.audit("INFO", "Backup exportiert", { this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind, kind: payloadObj.kind,
@ -803,8 +807,12 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
logger.info("App beendet"); logger.info("App beendet");
} }
private historyLimits(): { maxEntries: number; maxAgeDays: number } {
return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays };
}
public getHistory(): HistoryEntry[] { public getHistory(): HistoryEntry[] {
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits());
} }
public clearHistory(): void { public clearHistory(): void {

View File

@ -101,6 +101,8 @@ export function defaultSettings(): AppSettings {
theme: "dark" as const, theme: "dark" as const,
collapseNewPackages: true, collapseNewPackages: true,
historyRetentionMode: "permanent", historyRetentionMode: "permanent",
historyMaxEntries: 500,
historyMaxAgeDays: 0,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
autoSortPackagesByProgress: true, autoSortPackagesByProgress: true,
autoSkipExtracted: false, autoSkipExtracted: false,

View File

@ -451,6 +451,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode) historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode)
? settings.historyRetentionMode ? settings.historyRetentionMode
: defaults.historyRetentionMode, : defaults.historyRetentionMode,
historyMaxEntries: clampNumber(settings.historyMaxEntries, defaults.historyMaxEntries, 50, 100000),
historyMaxAgeDays: clampNumber(settings.historyMaxAgeDays, defaults.historyMaxAgeDays, 0, 3650),
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined
? Boolean(settings.accountListShowDetailedDebridLinkKeys) ? Boolean(settings.accountListShowDetailedDebridLinkKeys)
: defaults.accountListShowDetailedDebridLinkKeys, : defaults.accountListShowDetailedDebridLinkKeys,
@ -1129,6 +1131,23 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat
} }
const MAX_HISTORY_ENTRIES = 500; 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 { export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null {
const entry = asRecord(raw); 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); ensureBaseDir(paths.baseDir);
if (!fs.existsSync(paths.historyFile)) { if (!fs.existsSync(paths.historyFile)) {
return []; return [];
@ -1164,19 +1183,19 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
const entries: HistoryEntry[] = []; 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); const normalized = normalizeHistoryEntry(raw[i], i);
if (normalized) entries.push(normalized); if (normalized) entries.push(normalized);
} }
return entries; return pruneHistoryEntries(entries, limits);
} catch { } catch {
return []; return [];
} }
} }
export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void { export function saveHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES); const trimmed = pruneHistoryEntries(entries, limits);
const payload = JSON.stringify(trimmed, safeJsonReplacer, 2); const payload = JSON.stringify(trimmed, safeJsonReplacer, 2);
const tempPath = `${paths.historyFile}.tmp`; const tempPath = `${paths.historyFile}.tmp`;
try { try {
@ -1188,22 +1207,22 @@ export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void
} }
} }
export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry): HistoryEntry[] { export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] {
const existing = loadHistory(paths); const existing = loadHistory(paths, limits);
const updated = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES); const updated = pruneHistoryEntries([entry, ...existing], limits);
saveHistory(paths, updated); saveHistory(paths, updated, limits);
return updated; return updated;
} }
export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): HistoryEntry[] { export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, limits?: HistoryLimits): HistoryEntry[] {
return retentionMode === "never" ? [] : loadHistory(paths); 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") { if (retentionMode === "never") {
return []; return [];
} }
return addHistoryEntry(paths, entry); return addHistoryEntry(paths, entry, limits);
} }
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void { export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {

View File

@ -852,7 +852,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, 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, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@ -5077,34 +5077,66 @@ export function App(): ReactElement {
{settingsSubTab === "allgemein" && ( {settingsSubTab === "allgemein" && (
<div className="settings-section card"> <div className="settings-section card">
<h3>Allgemein</h3> <h3>Allgemein</h3>
<div className="settings-section-intro">Grundeinstellungen für Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.</div>
<h4 className="settings-subhead first">Speicherort</h4>
<label>Download-Ordner</label> <label>Download-Ordner</label>
<div className="input-row"> <div className="input-row">
<input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} /> <input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} />
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wählen</button> <button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wählen</button>
</div> </div>
<div className="setting-hint">Zielordner für alle heruntergeladenen Dateien.</div>
<label>Paketname (optional)</label> <label>Paketname (optional)</label>
<input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} /> <input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} />
<div className="setting-hint">Voreingestellter Name für neue Pakete; leer = automatisch aus den Links.</div>
<h4 className="settings-subhead">Download-Verhalten</h4>
<div className="field-grid two"> <div className="field-grid two">
<div><label>Max. Downloads</label><input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(e) => setNum("maxParallel", Math.max(1, Math.min(50, Number(e.target.value) || 1)))} /></div> <div><label>Max. gleichzeitige Downloads</label><input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(e) => setNum("maxParallel", Math.max(1, Math.min(50, Number(e.target.value) || 1)))} /><div className="setting-hint">Gleichzeitige Downloads (1-50); höher lastet Leitung und Hoster-Slots stärker aus.</div></div>
<div><label>Auto-Retry Limit (0 = inf)</label><input type="number" min={0} max={99} value={settingsDraft.retryLimit} onChange={(e) => setNum("retryLimit", Math.max(0, Math.min(99, Number(e.target.value) || 0)))} /></div> <div><label>Automatische Wiederholungen</label><input type="number" min={0} max={99} value={settingsDraft.retryLimit} onChange={(e) => setNum("retryLimit", Math.max(0, Math.min(99, Number(e.target.value) || 0)))} /><div className="setting-hint">Wiederholungen pro Datei bei Fehlern; 0 = unbegrenzt weiterversuchen.</div></div>
</div> </div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Beim Start automatisch fortsetzen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt</label> <div className="setting-hint">Unterbrochene Downloads beim Programmstart automatisch fortsetzen.</div>
<div>
<label>Verlauf speichern</label>
<select value={settingsDraft.historyRetentionMode} onChange={(e) => setText("historyRetentionMode", e.target.value)}>
{Object.entries(historyRetentionLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<div className="hint">Nie = kein Verlauf. Nur aktuelle Session = wird beim Beenden gelöscht. Dauerhaft = bleibt wie bisher gespeichert.</div>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSortPackagesByProgress} onChange={(e) => setBool("autoSortPackagesByProgress", e.target.checked)} /> Automatisches Sortieren laufender Pakete nach Fortschritt</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label> <div className="setting-hint">Kopierte Links werden automatisch erkannt und zur Liste hinzugefügt.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste in Sicherung mitsichern (Standard: nur Einstellungen)</label> <h4 className="settings-subhead">Verlauf</h4>
<label>Webhook-URL (Discord)</label> <label>Verlauf speichern</label>
<select value={settingsDraft.historyRetentionMode} onChange={(e) => setText("historyRetentionMode", e.target.value)}>
{Object.entries(historyRetentionLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<div className="setting-hint">Nie / nur Sitzung / dauerhaft; die Grenzen unten wirken nur bei Dauerhaft".</div>
<div className="field-grid two">
<div><label>Maximale Verlauf-Einträge</label><input type="number" min={50} max={100000} value={settingsDraft.historyMaxEntries} disabled={settingsDraft.historyRetentionMode !== "permanent"} onChange={(e) => setNum("historyMaxEntries", Math.max(50, Math.min(100000, Number(e.target.value) || 500)))} /><div className="setting-hint">Obergrenze (Standard 500); älteste Einträge fallen darüber hinaus weg.</div></div>
<div><label>Einträge löschen älter als (Tage)</label><input type="number" min={0} max={3650} value={settingsDraft.historyMaxAgeDays} disabled={settingsDraft.historyRetentionMode !== "permanent"} onChange={(e) => setNum("historyMaxAgeDays", Math.max(0, Math.min(3650, Number(e.target.value) || 0)))} /><div className="setting-hint">Löscht Einträge älter als X Tage; 0 = aus. Nur bei Dauerhaft" aktiv.</div></div>
</div>
<h4 className="settings-subhead">Oberfläche & Bedienung</h4>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt zeigen</label>
<div className="setting-hint">Neue Pakete starten zugeklappt; hält lange Listen übersichtlich.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSortPackagesByProgress} onChange={(e) => setBool("autoSortPackagesByProgress", e.target.checked)} /> Nach Fortschritt sortieren</label>
<div className="setting-hint">Laufende Pakete automatisch nach Fortschritt ordnen statt nach Reihenfolge.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In den Infobereich minimieren</label>
<div className="setting-hint">Legt das Fenster in den Tray statt zu beenden; läuft im Hintergrund weiter.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen nachfragen</label>
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
applyTheme(next as AppTheme);
}} /> Heller Modus</label>
<div className="setting-hint">Schaltet die Oberfläche von Dunkel auf Hell um.</div>
<h4 className="settings-subhead">Discord-Benachrichtigungen</h4>
<label>Webhook-Adresse (Discord)</label>
<div className="input-row"> <div className="input-row">
<input value={settingsDraft.notifyUrl} placeholder="https://discord.com/api/webhooks/..." onChange={(e) => setText("notifyUrl", e.target.value)} /> <input value={settingsDraft.notifyUrl} placeholder="https://discord.com/api/webhooks/..." onChange={(e) => setText("notifyUrl", e.target.value)} />
<button className="btn" disabled={actionBusy || !settingsDraft.notifyUrl.trim()} onClick={() => { <button className="btn" disabled={actionBusy || !settingsDraft.notifyUrl.trim()} onClick={() => {
@ -5120,22 +5152,16 @@ export function App(): ReactElement {
}); });
}}>Testen</button> }}>Testen</button>
</div> </div>
<div className="hint">In Discord: Servereinstellungen Integrationen Webhooks Neuer Webhook URL kopieren und hier eintragen. Die gewählten Ereignisse landen als Nachricht im Kanal.</div> <div className="setting-hint">Discord-Webhook für Meldungen; per Testen"-Button prüfbar. Leer = aus. In Discord: Servereinstellungen Integrationen Webhooks Neuer Webhook URL kopieren.</div>
<label>Discord-Ping (optional)</label> <label>Discord-Erwähnung (optional)</label>
<input value={settingsDraft.notifyMention} placeholder="Deine User-ID, @everyone oder @here" onChange={(e) => setText("notifyMention", e.target.value)} /> <input value={settingsDraft.notifyMention} placeholder="Deine User-ID, @everyone oder @here" onChange={(e) => setText("notifyMention", e.target.value)} />
<div className="hint">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.</div> <div className="setting-hint">Wird jeder Meldung vorangestellt (User-ID, @everyone, @here), damit Discord pingt.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageCompleted} onChange={(e) => setBool("notifyOnPackageCompleted", e.target.checked)} /> Benachrichtigen wenn ein Paket fertig ist</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageCompleted} onChange={(e) => setBool("notifyOnPackageCompleted", e.target.checked)} /> Melden, wenn ein Paket fertig ist</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageFailed} onChange={(e) => setBool("notifyOnPackageFailed", e.target.checked)} /> Benachrichtigen wenn ein Paket fehlschlägt</label> <div className="setting-hint">Sendet eine Meldung, sobald ein Paket vollständig geladen wurde.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnRunFinished} onChange={(e) => setBool("notifyOnRunFinished", e.target.checked)} /> Benachrichtigen wenn der Durchlauf beendet ist</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnPackageFailed} onChange={(e) => setBool("notifyOnPackageFailed", e.target.checked)} /> Melden, wenn ein Paket fehlschlägt</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => { <div className="setting-hint">Sendet eine Meldung, wenn ein Paket endgültig fehlschlägt.</div>
const next = e.target.checked ? "light" : "dark"; <label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnRunFinished} onChange={(e) => setBool("notifyOnRunFinished", e.target.checked)} /> Melden, wenn alles fertig ist</label>
settingsDraftRevisionRef.current += 1; <div className="setting-hint">Sendet eine Meldung, wenn die ganze Warteschlange abgearbeitet ist.</div>
panelDirtyRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
applyTheme(next as AppTheme);
}} /> Light Mode</label>
</div> </div>
)} )}
{settingsSubTab === "accounts" && ( {settingsSubTab === "accounts" && (
@ -5585,59 +5611,97 @@ export function App(): ReactElement {
{settingsSubTab === "entpacken" && ( {settingsSubTab === "entpacken" && (
<div className="settings-section card"> <div className="settings-section card">
<h3>Entpacken</h3> <h3>Entpacken</h3>
<div className="settings-section-intro">Wann und wie Archive entpackt werden, dazu Tonspur, Ablageform und Leistung.</div>
<h4 className="settings-subhead first">Ziel & Ablauf</h4>
<label>Entpacken nach</label> <label>Entpacken nach</label>
<div className="input-row"> <div className="input-row">
<input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} /> <input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} />
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wählen</button> <button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wählen</button>
</div> </div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label> <div className="setting-hint">Zielordner für die entpackten Dateien.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Automatisch entpacken</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label> <div className="setting-hint">Entpackt Archive automatisch, sobald das Paket vollständig geladen ist.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes überspringen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.keepGermanAudioOnly} onChange={(e) => setBool("keepGermanAudioOnly", e.target.checked)} /> Nur deutsche Tonspur behalten (.DL.-Dateien, braucht ffmpeg)</label> <div className="setting-hint">Überspringt beim Start Archive, deren Inhalt schon vorhanden ist.</div>
<div><label>Tonspur-Auswahl</label><select value={settingsDraft.germanAudioMode} disabled={!settingsDraft.keepGermanAudioOnly} onChange={(e) => setText("germanAudioMode", e.target.value)}> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Einträge ausblenden</label>
<div className="setting-hint">Blendet fertig entpackte Einträge aus der Paketliste aus.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Sitzung</label>
<div className="setting-hint">Entpackt offene Archive auch bei Stopp oder Programmstart ohne laufende Sitzung.</div>
<h4 className="settings-subhead">Deutsche Tonspur</h4>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.keepGermanAudioOnly} onChange={(e) => setBool("keepGermanAudioOnly", e.target.checked)} /> Nur deutsche Tonspur behalten</label>
<div className="setting-hint">Reduziert .DL.-Videos auf die deutsche Spur; benötigt ffmpeg.</div>
<label>Welche Tonspur behalten</label>
<select value={settingsDraft.germanAudioMode} disabled={!settingsDraft.keepGermanAudioOnly} onChange={(e) => setText("germanAudioMode", e.target.value)}>
<option value="tag">Deutsche Spur per Sprach-Tag (empfohlen)</option> <option value="tag">Deutsche Spur per Sprach-Tag (empfohlen)</option>
<option value="first">Immer erste Tonspur (wie Script)</option> <option value="first">Immer erste Tonspur (wie Script)</option>
</select></div> </select>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label> <div className="setting-hint">Deutsche Spur per Sprach-Kennung (empfohlen) oder erste Spur; nur aktiv wenn oben an.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label> <h4 className="settings-subhead">Ablageform</h4>
<div><label>Parallele Entpackungen</label><input type="number" min={1} max={8} value={settingsDraft.maxParallelExtract} onChange={(e) => setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} /></div> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Automatisch umbenennen (Beta)</label>
<div><label>Extraktions-Priorität</label><select value={settingsDraft.extractCpuPriority} onChange={(e) => setText("extractCpuPriority", e.target.value)}> <div className="setting-hint">Benennt kryptische 4sf/4sj-Dateinamen automatisch um (experimentell).</div>
<option value="high">Hoch (80% CPU)</option> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> In Paket-Unterordner ablegen</label>
<option value="middle">Mittel (50% CPU)</option> <div className="setting-hint">Legt entpackte Dateien je Paket in einen eigenen Unterordner.</div>
<option value="low">Niedrig (25% CPU)</option> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.collectMkvToLibrary} onChange={(e) => setBool("collectMkvToLibrary", e.target.checked)} /> Videos in Sammelordner verschieben</label>
</select></div> <div className="setting-hint">Verschiebt fertige Videos nach Paketabschluss in einen zentralen Ordner.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collectMkvToLibrary} onChange={(e) => setBool("collectMkvToLibrary", e.target.checked)} /> Videos nach Paketabschluss in Sammelordner verschieben (flach)</label>
<label>Video-Sammelordner</label> <label>Video-Sammelordner</label>
<div className="input-row"> <div className="input-row">
<input value={settingsDraft.mkvLibraryDir} onChange={(e) => setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} /> <input value={settingsDraft.mkvLibraryDir} onChange={(e) => setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} />
<button className="btn" disabled={!settingsDraft.collectMkvToLibrary} onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("mkvLibraryDir", s); } }); }}>Wählen</button> <button className="btn" disabled={!settingsDraft.collectMkvToLibrary} onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("mkvLibraryDir", s); } }); }}>Wählen</button>
</div> </div>
<label>Passwortliste (eine Zeile pro Passwort)</label> <div className="setting-hint">Zielordner für gesammelte Videos; nur aktiv wenn Sammeln eingeschaltet ist.</div>
<h4 className="settings-subhead">Leistung</h4>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Entpacken</label>
<div className="setting-hint">Entpackt schon während des Downloads weiter; spart Zeit, kostet mehr I/O.</div>
<div className="field-grid two">
<div><label>Gleichzeitige Entpackungen</label><input type="number" min={1} max={8} value={settingsDraft.maxParallelExtract} onChange={(e) => setNum("maxParallelExtract", Math.max(1, Math.min(8, Number(e.target.value) || 2)))} /><div className="setting-hint">Parallele Entpackvorgänge (1-8); höher braucht mehr CPU und Datenträger.</div></div>
<div><label>CPU-Priorität beim Entpacken</label><select value={settingsDraft.extractCpuPriority} onChange={(e) => setText("extractCpuPriority", e.target.value)}>
<option value="high">Hoch (80% CPU)</option>
<option value="middle">Mittel (50% CPU)</option>
<option value="low">Niedrig (25% CPU)</option>
</select><div className="setting-hint">CPU-Anteil beim Entpacken: Hoch 80%, Mittel 50%, Niedrig 25%.</div></div>
</div>
<h4 className="settings-subhead">Passwörter</h4>
<label>Passwortliste für Archive</label>
<textarea className="password-list" value={settingsDraft.archivePasswordList} onChange={(e) => setText("archivePasswordList", e.target.value)} placeholder={"serienfans.org\nserienjunkies.org\nmein-passwort"} /> <textarea className="password-list" value={settingsDraft.archivePasswordList} onChange={(e) => setText("archivePasswordList", e.target.value)} placeholder={"serienfans.org\nserienjunkies.org\nmein-passwort"} />
<div className="setting-hint">Ein Passwort pro Zeile; wird der Reihe nach an geschützten Archiven probiert.</div>
</div> </div>
)} )}
{settingsSubTab === "geschwindigkeit" && ( {settingsSubTab === "geschwindigkeit" && (
<div className="settings-section card"> <div className="settings-section card">
<h3>Geschwindigkeit</h3> <h3>Geschwindigkeit</h3>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit aktivieren</label> <div className="settings-section-intro">Tempo begrenzen, Verhalten bei Abbrüchen und zeitgesteuerte Bandbreitenregeln.</div>
<h4 className="settings-subhead first">Tempo-Begrenzung</h4>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Geschwindigkeit begrenzen</label>
<div className="setting-hint">Schaltet die Drosselung ein; die Felder unten wirken nur dann.</div>
<div className="field-grid two"> <div className="field-grid two">
<div> <div>
<label>Limit (MB/s)</label> <label>Höchstgeschwindigkeit (MB/s)</label>
<input type="number" min={0} step={0.1} value={speedLimitInput} onChange={(event) => setSpeedLimitInput(event.target.value)} onBlur={(event) => { const parsed = parseMbpsInput(event.target.value); if (parsed === null) { setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); return; } setSpeedLimitMbps(parsed); setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024))); }} disabled={!settingsDraft.speedLimitEnabled} /> <input type="number" min={0} step={0.1} value={speedLimitInput} onChange={(event) => setSpeedLimitInput(event.target.value)} onBlur={(event) => { const parsed = parseMbpsInput(event.target.value); if (parsed === null) { setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); return; } setSpeedLimitMbps(parsed); setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024))); }} disabled={!settingsDraft.speedLimitEnabled} />
<div className="setting-hint">Maximales Tempo in MB/s; 0 = unbegrenzt.</div>
</div> </div>
<div> <div>
<label>Limit-Modus</label> <label>Limit gilt für</label>
<select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)} disabled={!settingsDraft.speedLimitEnabled}> <select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)} disabled={!settingsDraft.speedLimitEnabled}>
<option value="global">Global</option> <option value="global">Global</option>
<option value="per_download">Pro Download</option> <option value="per_download">Pro Download</option>
</select> </select>
<div className="setting-hint">Global = Summe aller Downloads, Pro Download = je Download getrennt.</div>
</div> </div>
</div> </div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatischer Reconnect</label>
<div><label>Reconnect-Wartezeit (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Math.max(10, Math.min(600, Number(e.target.value) || 45)))} /></div> <h4 className="settings-subhead">Verbindung</h4>
<h4>Bandbreitenplanung</h4> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatisch neu verbinden</label>
<div className="setting-hint">Verbindet bei Abbruch automatisch neu, statt den Download zu beenden.</div>
<div><label>Wartezeit vor neuem Versuch (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Math.max(10, Math.min(600, Number(e.target.value) || 45)))} /><div className="setting-hint">Wartezeit vor dem nächsten Verbindungsversuch (10-600 Sek.).</div></div>
<h4 className="settings-subhead">Bandbreitenplanung</h4>
<div className="setting-hint">Pro Zeitfenster (Start-/End-Stunde) ein eigenes Limit, je Regel aktivierbar.</div>
{schedules.map((s, i) => { {schedules.map((s, i) => {
const scheduleKey = s.id || `schedule-${i}`; const scheduleKey = s.id || `schedule-${i}`;
const speedInput = scheduleSpeedInputs[scheduleKey] ?? formatMbpsInputFromKbps(s.speedLimitKbps); const speedInput = scheduleSpeedInputs[scheduleKey] ?? formatMbpsInputFromKbps(s.speedLimitKbps);
@ -5660,34 +5724,52 @@ export function App(): ReactElement {
{settingsSubTab === "bereinigung" && ( {settingsSubTab === "bereinigung" && (
<div className="settings-section card"> <div className="settings-section card">
<h3>Bereinigung</h3> <h3>Bereinigung</h3>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label> <div className="settings-section-intro">Integritätsprüfung sowie Aufräumen nach dem Entpacken und bei fertigen Downloads.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Samples nach Entpacken entfernen</label> <h4 className="settings-subhead first">Prüfung</h4>
<label>Fertiggestellte Downloads entfernen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> Dateien auf Fehler prüfen</label>
<div className="setting-hint">Prüft Prüfsummen (SFV/CRC/MD5/SHA1), um defekte Dateien zu erkennen.</div>
<h4 className="settings-subhead">Nach dem Entpacken</h4>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien danach entfernen</label>
<div className="setting-hint">Löscht übrig gebliebene Link-Dateien nach erfolgreichem Entpacken.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Vorschau-Dateien danach entfernen</label>
<div className="setting-hint">Löscht kleine Sample-Videos nach erfolgreichem Entpacken.</div>
<label>Archive nach dem Entpacken</label>
<select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}>
<option value="none">keine Archive löschen</option>
<option value="trash">Archive in Papierkorb</option>
<option value="delete">Archive löschen</option>
</select>
<div className="setting-hint">Mit Archiven nach Erfolg: behalten, in den Papierkorb oder löschen.</div>
<h4 className="settings-subhead">Fertige Downloads & Konflikte</h4>
<label>Fertige Downloads aus der Liste</label>
<select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}> <select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}>
{Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))} {Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
</select> </select>
<div className="field-grid two"> <div className="setting-hint">Wann erledigte Einträge verschwinden: nie, sofort, beim Start oder nach dem Paket.</div>
<div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}> <label>Bei gleichnamigen Dateien</label>
<option value="none">keine Archive löschen</option> <select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
<option value="trash">Archive in Papierkorb</option> <option value="overwrite">überschreiben</option>
<option value="delete">Archive löschen</option> <option value="skip">überspringen</option>
</select></div> <option value="rename">umbenennen</option>
<div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}> <option value="ask">nachfragen</option>
<option value="overwrite">überschreiben</option> </select>
<option value="skip">überspringen</option> <div className="setting-hint">Bei vorhandener Zieldatei: überschreiben, überspringen, umbenennen oder nachfragen.</div>
<option value="rename">umbenennen</option>
<option value="ask">nachfragen</option>
</select></div>
</div>
</div> </div>
)} )}
{settingsSubTab === "updates" && ( {settingsSubTab === "updates" && (
<div className="settings-section card"> <div className="settings-section card">
<h3>Updates</h3> <h3>Updates</h3>
<label>Codeberg Repo</label> <div className="settings-section-intro">Quelle und Zeitpunkt der Update-Prüfung.</div>
<h4 className="settings-subhead first">Aktualisierung</h4>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start nach Updates suchen</label>
<div className="setting-hint">Prüft beim Programmstart automatisch, ob eine neue Version verfügbar ist.</div>
<label>Update-Quelle</label>
<input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} /> <input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start auf Updates prüfen</label> <div className="setting-hint">Quelle für die Update-Prüfung (Benutzer/Repo). Im Zweifel unverändert lassen.</div>
</div> </div>
)} )}
</div> </div>

View File

@ -1015,6 +1015,42 @@ body,
min-height: 0; min-height: 0;
} }
.settings-section-intro {
color: var(--muted);
font-size: 12.5px;
line-height: 1.4;
margin: -2px 0 2px;
}
.settings-subhead {
margin: 16px 0 4px;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--muted);
}
.settings-subhead.first {
margin-top: 6px;
padding-top: 0;
border-top: 0;
}
.setting-hint {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
margin: -1px 0 7px;
}
.toggle-line + .setting-hint {
margin-top: -4px;
margin-left: 26px;
}
.settings-shell { .settings-shell {
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;

View File

@ -126,6 +126,8 @@ export interface AppSettings {
theme: AppTheme; theme: AppTheme;
collapseNewPackages: boolean; collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode; historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number;
historyMaxAgeDays: number;
accountListShowDetailedDebridLinkKeys: boolean; accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean; autoSortPackagesByProgress: boolean;
autoSkipExtracted: boolean; autoSkipExtracted: boolean;

View File

@ -332,6 +332,59 @@ describe("settings storage", () => {
expect(loadHistory(paths)).toEqual([]); expect(loadHistory(paths)).toEqual([]);
}); });
it("caps persisted history to the configured maxEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const now = Date.now();
const entries = Array.from({ length: 10 }, (_unused, i) => ({
id: `h-${i}`,
name: `e${i}`,
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: now - i * 1000,
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
}));
saveHistory(paths, entries, { maxEntries: 3, maxAgeDays: 0 });
const loaded = loadHistory(paths, { maxEntries: 3, maxAgeDays: 0 });
expect(loaded).toHaveLength(3);
expect(loaded.map((e) => e.id)).toEqual(["h-0", "h-1", "h-2"]);
});
it("drops history entries older than maxAgeDays", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
const fresh = {
id: "fresh",
name: "fresh",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: now - 2 * day,
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
const old = { ...fresh, id: "old", name: "old", completedAt: now - 40 * day };
saveHistory(paths, [fresh, old], { maxEntries: 500, maxAgeDays: 30 });
const loaded = loadHistory(paths, { maxEntries: 500, maxAgeDays: 30 });
expect(loaded.map((e) => e.id)).toEqual(["fresh"]);
});
it("assigns and preserves bandwidth schedule ids", () => { it("assigns and preserves bandwidth schedule ids", () => {
const normalized = normalizeSettings({ const normalized = normalizeSettings({
...defaultSettings(), ...defaultSettings(),