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:
parent
3a37c5a537
commit
de154ab783
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
||||||
|
<div className="setting-hint">Kopierte Links werden automatisch erkannt und zur Liste hinzugefügt.</div>
|
||||||
|
|
||||||
|
<h4 className="settings-subhead">Verlauf</h4>
|
||||||
<label>Verlauf speichern</label>
|
<label>Verlauf speichern</label>
|
||||||
<select value={settingsDraft.historyRetentionMode} onChange={(e) => setText("historyRetentionMode", e.target.value)}>
|
<select value={settingsDraft.historyRetentionMode} onChange={(e) => setText("historyRetentionMode", e.target.value)}>
|
||||||
{Object.entries(historyRetentionLabels).map(([key, label]) => (
|
{Object.entries(historyRetentionLabels).map(([key, label]) => (
|
||||||
<option key={key} value={key}>{label}</option>
|
<option key={key} value={key}>{label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className="hint">Nie = kein Verlauf. Nur aktuelle Session = wird beim Beenden gelöscht. Dauerhaft = bleibt wie bisher gespeichert.</div>
|
<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>
|
</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>
|
<h4 className="settings-subhead">Oberfläche & Bedienung</h4>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt zeigen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
|
<div className="setting-hint">Neue Pakete starten zugeklappt; hält lange Listen übersichtlich.</div>
|
||||||
<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>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSortPackagesByProgress} onChange={(e) => setBool("autoSortPackagesByProgress", e.target.checked)} /> Nach Fortschritt sortieren</label>
|
||||||
<label>Webhook-URL (Discord)</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>
|
||||||
<select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}>
|
<div className="setting-hint">Prüft Prüfsummen (SFV/CRC/MD5/SHA1), um defekte Dateien zu erkennen.</div>
|
||||||
{Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
|
|
||||||
</select>
|
<h4 className="settings-subhead">Nach dem Entpacken</h4>
|
||||||
<div className="field-grid two">
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien danach entfernen</label>
|
||||||
<div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}>
|
<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="none">keine Archive löschen</option>
|
||||||
<option value="trash">Archive in Papierkorb</option>
|
<option value="trash">Archive in Papierkorb</option>
|
||||||
<option value="delete">Archive löschen</option>
|
<option value="delete">Archive löschen</option>
|
||||||
</select></div>
|
</select>
|
||||||
<div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
|
<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)}>
|
||||||
|
{Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
|
||||||
|
</select>
|
||||||
|
<div className="setting-hint">Wann erledigte Einträge verschwinden: nie, sofort, beim Start oder nach dem Paket.</div>
|
||||||
|
<label>Bei gleichnamigen Dateien</label>
|
||||||
|
<select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
|
||||||
<option value="overwrite">überschreiben</option>
|
<option value="overwrite">überschreiben</option>
|
||||||
<option value="skip">überspringen</option>
|
<option value="skip">überspringen</option>
|
||||||
<option value="rename">umbenennen</option>
|
<option value="rename">umbenennen</option>
|
||||||
<option value="ask">nachfragen</option>
|
<option value="ask">nachfragen</option>
|
||||||
</select></div>
|
</select>
|
||||||
</div>
|
<div className="setting-hint">Bei vorhandener Zieldatei: überschreiben, überspringen, umbenennen oder nachfragen.</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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user