Fix history timing and retention controls
This commit is contained in:
parent
09da670eeb
commit
1afce943ae
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user