Fix history timing and retention controls

This commit is contained in:
Sucukdeluxe 2026-03-09 05:16:41 +01:00
parent 09da670eeb
commit 1afce943ae
9 changed files with 246 additions and 18 deletions

View File

@ -32,7 +32,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, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveHistory, saveSession, saveSettings } from "./storage"; import { addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, 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";
@ -87,6 +87,7 @@ export class AppController {
initRenameLog(this.storagePaths.baseDir); initRenameLog(this.storagePaths.baseDir);
initTraceLog(this.storagePaths.baseDir); initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const session = loadSession(this.storagePaths); const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({ this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin, login: this.settings.megaLogin,
@ -102,7 +103,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) => {
addHistoryEntry(this.storagePaths, entry); addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry);
} }
}); });
this.manager.on("state", (snapshot: UiSnapshot) => { this.manager.on("state", (snapshot: UiSnapshot) => {
@ -253,7 +254,11 @@ export class AppController {
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries( nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
); );
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
this.settings = nextSettings; this.settings = nextSettings;
if (retentionChanged) {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
}
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
this.audit("INFO", "Einstellungen aktualisiert", { this.audit("INFO", "Einstellungen aktualisiert", {
@ -532,7 +537,7 @@ export class AppController {
public exportBackup(): Buffer { public exportBackup(): Buffer {
const settings = { ...this.settings }; const settings = { ...this.settings };
const session = this.manager.getSession(); const session = this.manager.getSession();
const history = loadHistory(this.storagePaths); const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const payload = JSON.stringify({ const payload = JSON.stringify({
version: 2, version: 2,
appVersion: APP_VERSION, appVersion: APP_VERSION,
@ -622,6 +627,8 @@ export class AppController {
} }
} }
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
// Prevent prepareForShutdown from overwriting the restored data // Prevent prepareForShutdown from overwriting the restored data
this.manager.skipShutdownPersist = true; this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true; this.manager.blockAllPersistence = true;
@ -664,11 +671,14 @@ export class AppController {
this.audit("INFO", "App beendet"); this.audit("INFO", "App beendet");
shutdownTraceLog(); shutdownTraceLog();
shutdownAuditLog(); shutdownAuditLog();
if (this.settings.historyRetentionMode === "session") {
clearHistory(this.storagePaths);
}
logger.info("App beendet"); logger.info("App beendet");
} }
public getHistory(): HistoryEntry[] { public getHistory(): HistoryEntry[] {
return loadHistory(this.storagePaths); return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
} }
public clearHistory(): void { public clearHistory(): void {

View File

@ -97,6 +97,7 @@ export function defaultSettings(): AppSettings {
minimizeToTray: false, minimizeToTray: false,
theme: "dark" as const, theme: "dark" as const,
collapseNewPackages: true, collapseNewPackages: true,
historyRetentionMode: "permanent",
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
autoSortPackagesByProgress: true, autoSortPackagesByProgress: true,
autoSkipExtracted: false, autoSkipExtracted: false,

View File

@ -2129,6 +2129,8 @@ export class DownloadManager extends EventEmitter {
cancelled: false, cancelled: false,
enabled: true, enabled: true,
priority: "normal", priority: "normal",
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: nowMs(), createdAt: nowMs(),
updatedAt: nowMs() updatedAt: nowMs()
}; };
@ -4958,8 +4960,10 @@ export class DownloadManager extends EventEmitter {
} }
item.progressPercent = 100; item.progressPercent = 100;
item.speedBps = 0; item.speedBps = 0;
item.updatedAt = nowMs(); const finalizedAt = nowMs();
pkg.updatedAt = nowMs(); item.updatedAt = finalizedAt;
this.notePackageDownloadCompleted(pkg, finalizedAt);
pkg.updatedAt = finalizedAt;
this.recordRunOutcome(item.id, "completed"); this.recordRunOutcome(item.id, "completed");
if (this.session.running) { if (this.session.running) {
@ -5732,6 +5736,27 @@ export class DownloadManager extends EventEmitter {
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`));
} }
private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void {
if ((pkg.downloadStartedAt || 0) <= 0) {
pkg.downloadStartedAt = startedAt;
}
}
private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void {
this.notePackageDownloadStarted(pkg, completedAt);
pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt);
}
private getPackageHistoryDurationSeconds(pkg: PackageEntry): number {
const startedAt = pkg.downloadStartedAt > 0 ? pkg.downloadStartedAt : pkg.createdAt;
const finishedAtCandidate = pkg.downloadCompletedAt > 0 ? pkg.downloadCompletedAt : nowMs();
const finishedAt = Math.max(startedAt || 0, finishedAtCandidate || 0);
if (startedAt <= 0 || finishedAt <= 0) {
return 1;
}
return Math.max(1, Math.floor((finishedAt - startedAt) / 1000));
}
private recordPackageHistory(packageId: string, pkg: PackageEntry, items: DownloadItem[]): void { private recordPackageHistory(packageId: string, pkg: PackageEntry, items: DownloadItem[]): void {
if (!this.onHistoryEntryCallback || this.historyRecordedPackages.has(packageId)) { if (!this.onHistoryEntryCallback || this.historyRecordedPackages.has(packageId)) {
return; return;
@ -5742,7 +5767,7 @@ export class DownloadManager extends EventEmitter {
} }
this.historyRecordedPackages.add(packageId); this.historyRecordedPackages.add(packageId);
const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
const durationSeconds = pkg.createdAt > 0 ? Math.max(1, Math.floor((nowMs() - pkg.createdAt) / 1000)) : 1; const durationSeconds = this.getPackageHistoryDurationSeconds(pkg);
const providers = new Set(completedItems.map(item => item.provider).filter(Boolean)); const providers = new Set(completedItems.map(item => item.provider).filter(Boolean));
const provider = providers.size === 1 ? [...providers][0] : null; const provider = providers.size === 1 ? [...providers][0] : null;
const entry: HistoryEntry = { const entry: HistoryEntry = {
@ -5776,7 +5801,7 @@ export class DownloadManager extends EventEmitter {
const completedCount = completedItems.length; const completedCount = completedItems.length;
if (completedCount > 0) { if (completedCount > 0) {
const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
const durationSeconds = pkg.createdAt > 0 ? Math.max(1, Math.floor((nowMs() - pkg.createdAt) / 1000)) : 1; const durationSeconds = this.getPackageHistoryDurationSeconds(pkg);
const providers = new Set(completedItems.map(item => item.provider).filter(Boolean)); const providers = new Set(completedItems.map(item => item.provider).filter(Boolean));
const provider = providers.size === 1 ? [...providers][0] : null; const provider = providers.size === 1 ? [...providers][0] : null;
const entry: HistoryEntry = { const entry: HistoryEntry = {
@ -6759,6 +6784,7 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
this.notePackageDownloadStarted(pkg);
item.status = "validating"; item.status = "validating";
item.fullStatus = "Link wird umgewandelt"; item.fullStatus = "Link wird umgewandelt";
item.speedBps = 0; item.speedBps = 0;
@ -7077,14 +7103,16 @@ export class DownloadManager extends EventEmitter {
throw new Error(`aborted:${active.abortReason}`); throw new Error(`aborted:${active.abortReason}`);
} }
const completedAt = nowMs();
item.status = "completed"; item.status = "completed";
item.fullStatus = this.settings.autoExtract item.fullStatus = this.settings.autoExtract
? "Entpacken - Ausstehend" ? "Entpacken - Ausstehend"
: `Fertig (${humanSize(item.downloadedBytes)})`; : `Fertig (${humanSize(item.downloadedBytes)})`;
item.progressPercent = 100; item.progressPercent = 100;
item.speedBps = 0; item.speedBps = 0;
item.updatedAt = nowMs(); item.updatedAt = completedAt;
pkg.updatedAt = nowMs(); this.notePackageDownloadCompleted(pkg, completedAt);
pkg.updatedAt = completedAt;
this.recordRunOutcome(item.id, "completed"); this.recordRunOutcome(item.id, "completed");
logger.info(`Download fertig: ${item.fileName} (${humanSize(item.downloadedBytes)}), pkg=${pkg.name}`); logger.info(`Download fertig: ${item.fileName} (${humanSize(item.downloadedBytes)}), pkg=${pkg.name}`);
this.logPackageForItem(item, "INFO", "Download abgeschlossen", { this.logPackageForItem(item, "INFO", "Download abgeschlossen", {

View File

@ -2,7 +2,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { defaultSettings } from "./constants"; import { defaultSettings } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
@ -15,6 +15,7 @@ const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "pack
const VALID_SPEED_MODES = new Set(["global", "per_download"]); const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]); const VALID_THEMES = new Set(["dark", "light"]);
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]); const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([ const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
@ -375,6 +376,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
clipboardWatch: Boolean(settings.clipboardWatch), clipboardWatch: Boolean(settings.clipboardWatch),
minimizeToTray: Boolean(settings.minimizeToTray), minimizeToTray: Boolean(settings.minimizeToTray),
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode)
? settings.historyRetentionMode
: defaults.historyRetentionMode,
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined
? Boolean(settings.accountListShowDetailedDebridLinkKeys) ? Boolean(settings.accountListShowDetailedDebridLinkKeys)
: defaults.accountListShowDetailedDebridLinkKeys, : defaults.accountListShowDetailedDebridLinkKeys,
@ -582,6 +586,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
cancelled: Boolean(pkg.cancelled), cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
}; };
@ -1013,6 +1019,24 @@ export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry): Histo
return updated; return updated;
} }
export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): HistoryEntry[] {
return retentionMode === "never" ? [] : loadHistory(paths);
}
export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry): HistoryEntry[] {
if (retentionMode === "never") {
return [];
}
return addHistoryEntry(paths, entry);
}
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {
if (retentionMode === "permanent") {
return;
}
clearHistory(paths);
}
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] { export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
const existing = loadHistory(paths); const existing = loadHistory(paths);
const updated = existing.filter(e => e.id !== entryId); const updated = existing.filter(e => e.id !== entryId);

View File

@ -785,7 +785,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, autoSortPackagesByProgress: true, autoSkipExtracted: false, confirmDeleteSelection: true, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
@ -814,6 +814,12 @@ const cleanupLabels: Record<string, string> = {
never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist" never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist"
}; };
const historyRetentionLabels: Record<AppSettings["historyRetentionMode"], string> = {
never: "Nie",
session: "Nur aktuelle Session",
permanent: "Dauerhaft"
};
const AUTO_RENDER_PACKAGE_LIMIT = 260; const AUTO_RENDER_PACKAGE_LIMIT = 260;
const providerLabels: Record<DebridProvider, string> = { const providerLabels: Record<DebridProvider, string> = {
@ -981,6 +987,10 @@ function formatRuntimeDuration(durationMs: number): string {
return `${formatUnit(months, "Monat", "Monate")}, ${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; return `${formatUnit(months, "Monat", "Monate")}, ${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`;
} }
function formatHistoryDuration(durationSeconds: number): string {
return formatRuntimeDuration(Math.max(0, durationSeconds || 0) * 1000);
}
function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string { function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string {
return source === "web" ? "Web-Login" : "API-Key"; return source === "web" ? "Web-Login" : "API-Key";
} }
@ -4293,7 +4303,8 @@ export function App(): ReactElement {
? `${selectedHistoryIds.size} von ${historyEntries.length} ausgewählt` ? `${selectedHistoryIds.size} von ${historyEntries.length} ausgewählt`
: `${historyEntries.length} Paket${historyEntries.length !== 1 ? "e" : ""} im Verlauf`} : `${historyEntries.length} Paket${historyEntries.length !== 1 ? "e" : ""} im Verlauf`}
</span> </span>
{selectedHistoryIds.size > 0 && ( <div className="history-toolbar-actions">
{selectedHistoryIds.size > 0 && (
<button className="btn danger" onClick={() => { <button className="btn danger" onClick={() => {
const idSet = new Set(selectedHistoryIds); const idSet = new Set(selectedHistoryIds);
void Promise.all([...idSet].map(id => window.rd.removeHistoryEntry(id))).then(() => { void Promise.all([...idSet].map(id => window.rd.removeHistoryEntry(id))).then(() => {
@ -4303,10 +4314,11 @@ export function App(): ReactElement {
void window.rd.getHistory().then((entries) => { setHistoryEntries(entries); setSelectedHistoryIds(new Set()); }).catch(() => {}); void window.rd.getHistory().then((entries) => { setHistoryEntries(entries); setSelectedHistoryIds(new Set()); }).catch(() => {});
}); });
}}>Ausgewählte entfernen ({selectedHistoryIds.size})</button> }}>Ausgewählte entfernen ({selectedHistoryIds.size})</button>
)} )}
{historyEntries.length > 0 && ( {historyEntries.length > 0 && (
<button className="btn danger" onClick={() => { void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {}); }}>Verlauf leeren</button> <button className="btn danger" onClick={() => { void window.rd.clearHistory().then(() => { setHistoryEntries([]); setSelectedHistoryIds(new Set()); }).catch(() => {}); }}>Verlauf leeren</button>
)} )}
</div>
</div> </div>
{historyEntries.length === 0 && <div className="empty">Noch keine abgeschlossenen Pakete im Verlauf.</div>} {historyEntries.length === 0 && <div className="empty">Noch keine abgeschlossenen Pakete im Verlauf.</div>}
{historyEntries.map((entry) => { {historyEntries.map((entry) => {
@ -4381,7 +4393,7 @@ export function App(): ReactElement {
<span className="history-label">Heruntergeladen</span> <span className="history-label">Heruntergeladen</span>
<span>{humanSize(entry.downloadedBytes)}</span> <span>{humanSize(entry.downloadedBytes)}</span>
<span className="history-label">Dauer</span> <span className="history-label">Dauer</span>
<span>{entry.durationSeconds >= 3600 ? `${Math.floor(entry.durationSeconds / 3600)}h ${Math.floor((entry.durationSeconds % 3600) / 60)}min` : entry.durationSeconds >= 60 ? `${Math.floor(entry.durationSeconds / 60)}min ${entry.durationSeconds % 60}s` : `${entry.durationSeconds}s`}</span> <span>{formatHistoryDuration(entry.durationSeconds)}</span>
<span className="history-label">Durchschnitt</span> <span className="history-label">Durchschnitt</span>
<span>{entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : ""}</span> <span>{entry.durationSeconds > 0 ? formatSpeedMbps(Math.round(entry.downloadedBytes / entry.durationSeconds)) : ""}</span>
<span className="history-label">Provider</span> <span className="history-label">Provider</span>
@ -4551,6 +4563,15 @@ export function App(): ReactElement {
</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)} /> Auto-Resume beim Start</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.collapseNewPackages} onChange={(e) => setBool("collapseNewPackages", e.target.checked)} /> Neue Pakete eingeklappt</label>
<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.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> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>

View File

@ -2243,12 +2243,18 @@ body,
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
height: 100%;
min-height: 0;
overflow: auto;
padding-right: 2px;
} }
.history-toolbar { .history-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 12px;
padding: 5px 12px; padding: 5px 12px;
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -2258,6 +2264,14 @@ body,
color: var(--muted); color: var(--muted);
} }
.history-toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
}
.history-card { .history-card {
cursor: default; cursor: default;
} }

View File

@ -29,6 +29,7 @@ export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light"; export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low"; export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low"; export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent";
export interface BandwidthScheduleEntry { export interface BandwidthScheduleEntry {
id: string; id: string;
@ -107,6 +108,7 @@ export interface AppSettings {
minimizeToTray: boolean; minimizeToTray: boolean;
theme: AppTheme; theme: AppTheme;
collapseNewPackages: boolean; collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
accountListShowDetailedDebridLinkKeys: boolean; accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean; autoSortPackagesByProgress: boolean;
autoSkipExtracted: boolean; autoSkipExtracted: boolean;
@ -167,6 +169,8 @@ export interface PackageEntry {
enabled: boolean; enabled: boolean;
priority: PackagePriority; priority: PackagePriority;
postProcessLabel?: string; postProcessLabel?: string;
downloadStartedAt?: number;
downloadCompletedAt?: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }

View File

@ -66,6 +66,69 @@ afterEach(async () => {
}); });
describe("download manager", () => { describe("download manager", () => {
it("records history duration from the first actual package start", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
tempDirs.push(root);
const historyEntries: Array<{ durationSeconds: number; downloadedBytes: number }> = [];
const manager = new DownloadManager(
defaultSettings(),
emptySession(),
createStoragePaths(path.join(root, "state")),
{
onHistoryEntry: (entry) => {
historyEntries.push({
durationSeconds: entry.durationSeconds,
downloadedBytes: entry.downloadedBytes
});
}
}
);
const packageId = "history-pkg";
const itemId = "history-item";
const pkg = {
id: packageId,
name: "History Test",
outputDir: path.join(root, "downloads", "History Test"),
extractDir: path.join(root, "extract", "History Test"),
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1_000,
updatedAt: 61_000,
downloadStartedAt: 15_000,
downloadCompletedAt: 60_000
};
const item = {
id: itemId,
packageId,
url: "https://example.com/history.rar",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 90 * 1024 * 1024,
totalBytes: 90 * 1024 * 1024,
progressPercent: 100,
fileName: "history.rar",
targetPath: path.join(pkg.outputDir, "history.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt: 15_000,
updatedAt: 60_000
};
(manager as any).recordPackageHistory(packageId, pkg, [item]);
expect(historyEntries).toHaveLength(1);
expect(historyEntries[0]?.durationSeconds).toBe(45);
expect(historyEntries[0]?.downloadedBytes).toBe(90 * 1024 * 1024);
});
function createCompletedArchiveSession(root: string, packageName: string, extractedFileName: string): { function createCompletedArchiveSession(root: string, packageName: string, extractedFileName: string): {
session: ReturnType<typeof emptySession>; session: ReturnType<typeof emptySession>;
packageId: string; packageId: string;

View File

@ -6,7 +6,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { AppSettings } from "../src/shared/types"; import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage"; import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@ -273,6 +273,65 @@ describe("settings storage", () => {
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false); expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
}); });
it("defaults history retention to permanent and normalizes invalid values", () => {
expect(defaultSettings().historyRetentionMode).toBe("permanent");
const normalized = normalizeSettings({
...defaultSettings(),
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
});
expect(normalized.historyRetentionMode).toBe("permanent");
});
it("skips adding persisted history entries when history retention is never", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const result = addHistoryEntryForRetention(paths, "never", {
id: "hist-1",
name: "ignored",
totalBytes: 1024,
downloadedBytes: 1024,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 12,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file.rar"]
});
expect(result).toEqual([]);
expect(loadHistory(paths)).toEqual([]);
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
});
it("clears persisted history for session retention mode", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{
id: "hist-2",
name: "kept",
totalBytes: 2048,
downloadedBytes: 2048,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 20,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file2.rar"]
}]);
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
});
it("assigns and preserves bandwidth schedule ids", () => { it("assigns and preserves bandwidth schedule ids", () => {
const normalized = normalizeSettings({ const normalized = normalizeSettings({
...defaultSettings(), ...defaultSettings(),
@ -305,6 +364,8 @@ describe("settings storage", () => {
itemIds: ["item1", "item2", "item3", "item4"], itemIds: ["item1", "item2", "item3", "item4"],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now() updatedAt: Date.now()
}; };
@ -438,6 +499,8 @@ describe("settings storage", () => {
itemIds: ["item-backup"], itemIds: ["item-backup"],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now() updatedAt: Date.now()
}; };