Add daily log rotation with monthly folder structure

All log output is now additionally written to daily log files:
  daily-logs/YYYY-MM/YYYY-MM-DD.log (main log)
  daily-logs/YYYY-MM/YYYY-MM-DD-rename.log (rename log)

Automatic cleanup of daily logs older than 30 days. The existing
rd_downloader.log and rename.log continue to work as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-24 09:16:24 +01:00
parent 1c78bb61c6
commit d7149829ea
3 changed files with 623 additions and 428 deletions

View File

@ -24,26 +24,27 @@ import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger"; import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web"; import { AllDebridWebFallback } from "./all-debrid-web";
import { BestDebridWebFallback } from "./bestdebrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web";
import { RealDebridWebFallback } from "./realdebrid-web"; import { RealDebridWebFallback } from "./realdebrid-web";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; 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, 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";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { initDailyLog, shutdownDailyLog } from "./daily-log";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> { function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@ -73,22 +74,23 @@ export class AppController {
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
private autoResumePending = false;
private runtimeStatsTimer: NodeJS.Timeout | null = null;
public constructor() { private autoResumePending = false;
configureLogger(this.storagePaths.baseDir); private runtimeStatsTimer: NodeJS.Timeout | null = null;
initSessionLog(this.storagePaths.baseDir);
initPackageLogs(this.storagePaths.baseDir); public constructor() {
initItemLogs(this.storagePaths.baseDir); configureLogger(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir); initSessionLog(this.storagePaths.baseDir);
initRenameLog(this.storagePaths.baseDir); initPackageLogs(this.storagePaths.baseDir);
initTraceLog(this.storagePaths.baseDir); initItemLogs(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); initAuditLog(this.storagePaths.baseDir);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); initRenameLog(this.storagePaths.baseDir);
const session = loadSession(this.storagePaths); initDailyLog(this.storagePaths.baseDir);
initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({ this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin, login: this.settings.megaLogin,
password: this.settings.megaPassword password: this.settings.megaPassword
@ -98,31 +100,31 @@ export class AppController {
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
this.manager = new DownloadManager(this.settings, session, this.storagePaths, { this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal),
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
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.manager.on("state", (snapshot: UiSnapshot) => { this.manager.on("state", (snapshot: UiSnapshot) => {
this.onStateHandler?.(snapshot); this.onStateHandler?.(snapshot);
}); });
logger.info(`App gestartet v${APP_VERSION}`); logger.info(`App gestartet v${APP_VERSION}`);
logger.info(`Log-Datei: ${getLogFilePath()}`); logger.info(`Log-Datei: ${getLogFilePath()}`);
logAuditEvent("INFO", "App gestartet", { logAuditEvent("INFO", "App gestartet", {
appVersion: APP_VERSION, appVersion: APP_VERSION,
runtimeDir: this.storagePaths.baseDir runtimeDir: this.storagePaths.baseDir
}); });
startDebugServer(this.manager, this.storagePaths.baseDir); startDebugServer(this.manager, this.storagePaths.baseDir);
this.runtimeStatsTimer = setInterval(() => { this.runtimeStatsTimer = setInterval(() => {
this.manager.persistRuntimeStats(); this.manager.persistRuntimeStats();
this.settings = this.manager.getSettings(); this.settings = this.manager.getSettings();
}, 60_000); }, 60_000);
this.runtimeStatsTimer.unref?.(); this.runtimeStatsTimer.unref?.();
if (this.settings.autoResumeOnStart) { if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot(); const snapshot = this.manager.getSnapshot();
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
if (hasPending) { if (hasPending) {
@ -187,46 +189,46 @@ export class AppController {
return APP_VERSION; return APP_VERSION;
} }
public getSettings(): AppSettings { public getSettings(): AppSettings {
return this.settings; return this.settings;
} }
public getAuditLogPath(): string | null { public getAuditLogPath(): string | null {
return getAuditLogPath(); return getAuditLogPath();
} }
public getRenameLogPath(): string | null { public getRenameLogPath(): string | null {
return getRenameLogPath(); return getRenameLogPath();
} }
public getTraceLogPath(): string | null { public getTraceLogPath(): string | null {
return getTraceLogPath(); return getTraceLogPath();
} }
public getTraceConfig(): SupportTraceConfig { public getTraceConfig(): SupportTraceConfig {
return getTraceConfig(); return getTraceConfig();
} }
public rotateDebugToken(): { path: string; token: string } { public rotateDebugToken(): { path: string; token: string } {
const rotated = rotateDebugToken(this.storagePaths.baseDir); const rotated = rotateDebugToken(this.storagePaths.baseDir);
this.audit("WARN", "Debug-Token rotiert", { path: rotated.path }); this.audit("WARN", "Debug-Token rotiert", { path: rotated.path });
return rotated; return rotated;
} }
public getDebugSetupCheck(): DebugSetupCheckResult { public getDebugSetupCheck(): DebugSetupCheckResult {
return getDebugSetupCheck(this.storagePaths.baseDir); return getDebugSetupCheck(this.storagePaths.baseDir);
} }
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void { private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields); logAuditEvent(level, message, fields);
logTraceEvent(level, "audit", message, fields); logTraceEvent(level, "audit", message, fields);
} }
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig { public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
const next = setTraceEnabled(enabled, note, durationMs); const next = setTraceEnabled(enabled, note, durationMs);
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
return next; return next;
} }
public updateSettings(partial: Partial<AppSettings>): AppSettings { public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial); const sanitizedPatch = sanitizeSettingsPatch(partial);
@ -240,32 +242,32 @@ export class AppController {
return previousSettings; return previousSettings;
} }
// Preserve the live all-time counters from the download manager // Preserve the live all-time counters from the download manager
const liveSettings = this.manager.getSettings(); const liveSettings = this.manager.getSettings();
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs()); nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay; nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) }; nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) }; nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries( nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
); );
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; const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
this.settings = nextSettings; this.settings = nextSettings;
if (retentionChanged) { if (retentionChanged) {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); 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", {
changedKeys: Object.keys(sanitizedPatch), changedKeys: Object.keys(sanitizedPatch),
accountChanges: diffAccountSummary(previousSettings, this.settings) accountChanges: diffAccountSummary(previousSettings, this.settings)
}); });
if (previousSettings.rememberToken && !this.settings.rememberToken) { if (previousSettings.rememberToken && !this.settings.rememberToken) {
void this.realDebridWebFallback.clearSessions().catch((error) => { void this.realDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
}); });
@ -285,12 +287,12 @@ export class AppController {
...liveSettings, ...liveSettings,
...resetProviderDailyUsage(liveSettings, provider) ...resetProviderDailyUsage(liveSettings, provider)
}); });
this.settings = nextSettings; this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider }); this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider });
return this.settings; return this.settings;
} }
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
const liveSettings = this.manager.getSettings(); const liveSettings = this.manager.getSettings();
@ -298,31 +300,31 @@ export class AppController {
...liveSettings, ...liveSettings,
...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId) ...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId)
}); });
this.settings = nextSettings; this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId }); this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
return this.settings; return this.settings;
} }
public async openRealDebridLoginWindow(): Promise<void> { public async openRealDebridLoginWindow(): Promise<void> {
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
await this.realDebridWebFallback.openLoginWindow(); await this.realDebridWebFallback.openLoginWindow();
} }
public async openAllDebridLoginWindow(): Promise<void> { public async openAllDebridLoginWindow(): Promise<void> {
this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
await this.allDebridWebFallback.openLoginWindow(); await this.allDebridWebFallback.openLoginWindow();
} }
public async importBestDebridCookies(filePath: string): Promise<number> { public async importBestDebridCookies(filePath: string): Promise<number> {
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath); const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
this.audit("INFO", "BestDebrid Cookies importiert", { this.audit("INFO", "BestDebrid Cookies importiert", {
filePath, filePath,
imported imported
}); });
return imported; return imported;
} }
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> { public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
if (this.settings.allDebridUseWebLogin) { if (this.settings.allDebridUseWebLogin) {
@ -367,38 +369,38 @@ export class AppController {
return result; return result;
} }
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
if (parsed.length === 0) { if (parsed.length === 0) {
this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", { this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", {
hasPackageName: Boolean(payload.packageName) hasPackageName: Boolean(payload.packageName)
}); });
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 }; return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
} }
const result = this.manager.addPackages(parsed); const result = this.manager.addPackages(parsed);
this.audit("INFO", "Links hinzugefügt", { this.audit("INFO", "Links hinzugefügt", {
addedPackages: result.addedPackages, addedPackages: result.addedPackages,
addedLinks: result.addedLinks, addedLinks: result.addedLinks,
requestedPackages: parsed.length requestedPackages: parsed.length
}); });
return { ...result, invalidCount: 0 }; return { ...result, invalidCount: 0 };
} }
public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> {
const packages = await importDlcContainers(filePaths); const packages = await importDlcContainers(filePaths);
const merged: ParsedPackageInput[] = packages.map((pkg) => ({ const merged: ParsedPackageInput[] = packages.map((pkg) => ({
name: pkg.name, name: pkg.name,
links: pkg.links, links: pkg.links,
...(pkg.fileNames ? { fileNames: pkg.fileNames } : {}) ...(pkg.fileNames ? { fileNames: pkg.fileNames } : {})
})); }));
const result = this.manager.addPackages(merged); const result = this.manager.addPackages(merged);
this.audit("INFO", "Container importiert", { this.audit("INFO", "Container importiert", {
files: filePaths.length, files: filePaths.length,
addedPackages: result.addedPackages, addedPackages: result.addedPackages,
addedLinks: result.addedLinks addedLinks: result.addedLinks
}); });
return result; return result;
} }
public async getStartConflicts(): Promise<StartConflictEntry[]> { public async getStartConflicts(): Promise<StartConflictEntry[]> {
return this.manager.getStartConflicts(); return this.manager.getStartConflicts();
@ -408,163 +410,163 @@ export class AppController {
return this.manager.resolveStartConflict(packageId, policy); return this.manager.resolveStartConflict(packageId, policy);
} }
public clearAll(): void { public clearAll(): void {
this.audit("WARN", "Queue komplett geleert"); this.audit("WARN", "Queue komplett geleert");
this.manager.clearAll(); this.manager.clearAll();
} }
public async start(): Promise<void> {
this.audit("INFO", "Session-Start ausgelöst");
await this.manager.start();
}
public async startPackages(packageIds: string[]): Promise<void> {
this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
await this.manager.startPackages(packageIds);
}
public async startItems(itemIds: string[]): Promise<void> {
this.audit("INFO", "Item-Start ausgelöst", { itemIds });
await this.manager.startItems(itemIds);
}
public stop(): void {
this.audit("INFO", "Session-Stopp ausgelöst");
this.manager.stop();
}
public togglePause(): boolean {
const paused = this.manager.togglePause();
this.audit("INFO", "Pause umgeschaltet", { paused });
return paused;
}
public retryExtraction(packageId: string): void {
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
this.manager.retryExtraction(packageId);
}
public extractNow(packageId: string): void {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
this.manager.extractNow(packageId);
}
public resetPackage(packageId: string): void {
this.audit("INFO", "Paket zurückgesetzt", { packageId });
this.manager.resetPackage(packageId);
}
public cancelPackage(packageId: string): void {
this.audit("WARN", "Paket abgebrochen", { packageId });
this.manager.cancelPackage(packageId);
}
public renamePackage(packageId: string, newName: string): void {
this.audit("INFO", "Paket umbenannt", { packageId, newName });
this.manager.renamePackage(packageId, newName);
}
public reorderPackages(packageIds: string[]): void {
this.audit("INFO", "Paketreihenfolge geändert", { packageIds });
this.manager.reorderPackages(packageIds);
}
public removeItem(itemId: string): void {
this.audit("WARN", "Item entfernt", { itemId });
this.manager.removeItem(itemId);
}
public togglePackage(packageId: string): void { public async start(): Promise<void> {
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId }); this.audit("INFO", "Session-Start ausgelöst");
this.manager.togglePackage(packageId); await this.manager.start();
} }
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
this.audit("INFO", "Paket-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
packageIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
this.audit("INFO", "Item-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
itemIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportQueue(): string {
return this.manager.exportQueue();
}
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
const result = this.manager.importQueue(json);
this.audit("INFO", "Import-Datei verarbeitet", result);
return result;
}
public getSessionStats(): SessionStats { public async startPackages(packageIds: string[]): Promise<void> {
return this.manager.getSessionStats(); this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
} await this.manager.startPackages(packageIds);
}
public resetSessionStats(): void {
this.audit("INFO", "Session-Statistik zurückgesetzt");
this.manager.resetSessionStats();
}
public resetDownloadStats(): void {
this.manager.resetDownloadStats();
this.settings = this.manager.getSettings();
this.audit("INFO", "Download-Statistik zurückgesetzt");
}
public exportBackup(): Buffer { public async startItems(itemIds: string[]): Promise<void> {
const settings = { ...this.settings }; this.audit("INFO", "Item-Start ausgelöst", { itemIds });
const session = this.manager.getSession(); await this.manager.startItems(itemIds);
const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); }
public stop(): void {
this.audit("INFO", "Session-Stopp ausgelöst");
this.manager.stop();
}
public togglePause(): boolean {
const paused = this.manager.togglePause();
this.audit("INFO", "Pause umgeschaltet", { paused });
return paused;
}
public retryExtraction(packageId: string): void {
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
this.manager.retryExtraction(packageId);
}
public extractNow(packageId: string): void {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
this.manager.extractNow(packageId);
}
public resetPackage(packageId: string): void {
this.audit("INFO", "Paket zurückgesetzt", { packageId });
this.manager.resetPackage(packageId);
}
public cancelPackage(packageId: string): void {
this.audit("WARN", "Paket abgebrochen", { packageId });
this.manager.cancelPackage(packageId);
}
public renamePackage(packageId: string, newName: string): void {
this.audit("INFO", "Paket umbenannt", { packageId, newName });
this.manager.renamePackage(packageId, newName);
}
public reorderPackages(packageIds: string[]): void {
this.audit("INFO", "Paketreihenfolge geändert", { packageIds });
this.manager.reorderPackages(packageIds);
}
public removeItem(itemId: string): void {
this.audit("WARN", "Item entfernt", { itemId });
this.manager.removeItem(itemId);
}
public togglePackage(packageId: string): void {
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId });
this.manager.togglePackage(packageId);
}
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
this.audit("INFO", "Paket-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
packageIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
this.audit("INFO", "Item-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
itemIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportQueue(): string {
return this.manager.exportQueue();
}
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
const result = this.manager.importQueue(json);
this.audit("INFO", "Import-Datei verarbeitet", result);
return result;
}
public getSessionStats(): SessionStats {
return this.manager.getSessionStats();
}
public resetSessionStats(): void {
this.audit("INFO", "Session-Statistik zurückgesetzt");
this.manager.resetSessionStats();
}
public resetDownloadStats(): void {
this.manager.resetDownloadStats();
this.settings = this.manager.getSettings();
this.audit("INFO", "Download-Statistik zurückgesetzt");
}
public exportBackup(): Buffer {
const settings = { ...this.settings };
const session = this.manager.getSession();
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,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
settings, settings,
session, session,
history history
}); });
this.audit("INFO", "Backup exportiert", { this.audit("INFO", "Backup exportiert", {
historyEntries: history.length, historyEntries: history.length,
sessionItems: Object.keys(session.items).length, sessionItems: Object.keys(session.items).length,
sessionPackages: Object.keys(session.packages).length sessionPackages: Object.keys(session.packages).length
}); });
return encryptBackup(payload); return encryptBackup(payload);
} }
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
this.audit("INFO", "Support-Bundle exportiert"); this.audit("INFO", "Support-Bundle exportiert");
logTraceEvent("INFO", "support", "Support-Bundle erstellt", { logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
itemCount: Object.keys(this.manager.getSnapshot().session.items).length itemCount: Object.keys(this.manager.getSnapshot().session.items).length
}); });
return { return {
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir), buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir),
defaultFileName: getSupportBundleDefaultFileName() defaultFileName: getSupportBundleDefaultFileName()
}; };
} }
public importBackup(data: Buffer): { restored: boolean; message: string } { public importBackup(data: Buffer): { restored: boolean; message: string } {
let parsed: Record<string, unknown>; let parsed: Record<string, unknown>;
@ -586,21 +588,21 @@ export class AppController {
} }
// Restore settings — ALL credentials are included (no more masking) // Restore settings — ALL credentials are included (no more masking)
const importedSettings = parsed.settings as AppSettings; const importedSettings = parsed.settings as AppSettings;
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>; const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>; const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
// Legacy backup compatibility: if credentials were masked with ***, keep current values // Legacy backup compatibility: if credentials were masked with ***, keep current values
const SENSITIVE_KEYS: (keyof AppSettings)[] = [ const SENSITIVE_KEYS: (keyof AppSettings)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword" "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
]; ];
for (const key of SENSITIVE_KEYS) { for (const key of SENSITIVE_KEYS) {
const val = importedSettingsRecord[key]; const val = importedSettingsRecord[key];
if (typeof val === "string" && val.startsWith("***")) { if (typeof val === "string" && val.startsWith("***")) {
importedSettingsRecord[key] = currentSettingsRecord[key]; importedSettingsRecord[key] = currentSettingsRecord[key];
} }
} }
const restoredSettings = normalizeSettings(importedSettings); const restoredSettings = normalizeSettings(importedSettings);
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
@ -629,93 +631,94 @@ export class AppController {
} }
} }
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); 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;
logger.info("Backup wiederhergestellt (verschlüsseltes Format)"); logger.info("Backup wiederhergestellt (verschlüsseltes Format)");
this.audit("WARN", "Backup importiert", { this.audit("WARN", "Backup importiert", {
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
accountSummary: buildAccountSummary(this.settings) accountSummary: buildAccountSummary(this.settings)
}); });
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
} }
public getSessionLogPath(): string | null { public getSessionLogPath(): string | null {
return getSessionLogPath(); return getSessionLogPath();
} }
public getPackageLogPath(packageId: string): string | null { public getPackageLogPath(packageId: string): string | null {
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId); return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
} }
public getItemLogPath(itemId: string): string | null { public getItemLogPath(itemId: string): string | null {
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
} }
public shutdown(): void { public shutdown(): void {
if (this.runtimeStatsTimer) { if (this.runtimeStatsTimer) {
clearInterval(this.runtimeStatsTimer); clearInterval(this.runtimeStatsTimer);
this.runtimeStatsTimer = null; this.runtimeStatsTimer = null;
} }
stopDebugServer(); stopDebugServer();
abortActiveUpdateDownload(); abortActiveUpdateDownload();
this.manager.prepareForShutdown(); this.manager.prepareForShutdown();
this.megaWebFallback.dispose(); this.megaWebFallback.dispose();
this.realDebridWebFallback.dispose(); this.realDebridWebFallback.dispose();
this.allDebridWebFallback.dispose(); this.allDebridWebFallback.dispose();
this.bestDebridWebFallback.dispose(); this.bestDebridWebFallback.dispose();
shutdownSessionLog(); shutdownSessionLog();
shutdownPackageLogs(); shutdownPackageLogs();
shutdownItemLogs(); shutdownItemLogs();
shutdownRenameLog(); shutdownRenameLog();
this.audit("INFO", "App beendet"); shutdownDailyLog();
shutdownTraceLog(); this.audit("INFO", "App beendet");
shutdownAuditLog(); shutdownTraceLog();
if (this.settings.historyRetentionMode === "session") { shutdownAuditLog();
clearHistory(this.storagePaths); if (this.settings.historyRetentionMode === "session") {
} clearHistory(this.storagePaths);
logger.info("App beendet"); }
} logger.info("App beendet");
}
public getHistory(): HistoryEntry[] {
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
}
public clearHistory(): void { public getHistory(): HistoryEntry[] {
this.audit("WARN", "Verlauf geleert"); return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
clearHistory(this.storagePaths); }
}
public clearHistory(): void {
public setPackagePriority(packageId: string, priority: PackagePriority): void { this.audit("WARN", "Verlauf geleert");
this.audit("INFO", "Paket-Priorität geändert", { packageId, priority }); clearHistory(this.storagePaths);
this.manager.setPackagePriority(packageId, priority); }
}
public setPackagePriority(packageId: string, priority: PackagePriority): void {
public skipItems(itemIds: string[]): void { this.audit("INFO", "Paket-Priorität geändert", { packageId, priority });
this.audit("INFO", "Items übersprungen", { itemIds }); this.manager.setPackagePriority(packageId, priority);
this.manager.skipItems(itemIds); }
}
public skipItems(itemIds: string[]): void {
public resetItems(itemIds: string[]): void { this.audit("INFO", "Items übersprungen", { itemIds });
this.audit("INFO", "Items zurückgesetzt", { itemIds }); this.manager.skipItems(itemIds);
this.manager.resetItems(itemIds); }
}
public resetItems(itemIds: string[]): void {
public removeHistoryEntry(entryId: string): void { this.audit("INFO", "Items zurückgesetzt", { itemIds });
this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); this.manager.resetItems(itemIds);
removeHistoryEntry(this.storagePaths, entryId); }
}
public removeHistoryEntry(entryId: string): void {
public addToHistory(entry: HistoryEntry): void { this.audit("INFO", "Verlaufseintrag entfernt", { entryId });
this.audit("INFO", "Verlaufseintrag hinzugefügt", { removeHistoryEntry(this.storagePaths, entryId);
id: entry.id, }
name: entry.name,
status: entry.status, public addToHistory(entry: HistoryEntry): void {
provider: entry.provider, this.audit("INFO", "Verlaufseintrag hinzugefügt", {
fileCount: entry.fileCount id: entry.id,
}); name: entry.name,
addHistoryEntry(this.storagePaths, entry); status: entry.status,
} provider: entry.provider,
fileCount: entry.fileCount
});
addHistoryEntry(this.storagePaths, entry);
}
} }

193
src/main/daily-log.ts Normal file
View File

@ -0,0 +1,193 @@
import fs from "node:fs";
import path from "node:path";
import { addLogListener, removeLogListener } from "./logger";
const DAILY_LOG_RETENTION_DAYS = 30;
const CLEANUP_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6 hours
let dailyLogDir = "";
let currentDayKey = "";
let currentLogFd: number | null = null;
let currentRenameFd: number | null = null;
let logListener: ((line: string) => void) | null = null;
let cleanupTimer: NodeJS.Timeout | null = null;
let lastCleanupAt = 0;
function getDayKey(now = new Date()): string {
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function getMonthDir(dayKey: string): string {
return dayKey.slice(0, 7); // "YYYY-MM"
}
function ensureDayFile(dayKey: string): number | null {
if (currentDayKey === dayKey && currentLogFd !== null) {
return currentLogFd;
}
// Close previous day's fd
if (currentLogFd !== null) {
try { fs.closeSync(currentLogFd); } catch { /* ignore */ }
currentLogFd = null;
}
if (currentRenameFd !== null) {
try { fs.closeSync(currentRenameFd); } catch { /* ignore */ }
currentRenameFd = null;
}
currentDayKey = dayKey;
const monthDir = path.join(dailyLogDir, getMonthDir(dayKey));
try {
fs.mkdirSync(monthDir, { recursive: true });
const filePath = path.join(monthDir, `${dayKey}.log`);
currentLogFd = fs.openSync(filePath, "a");
return currentLogFd;
} catch {
return null;
}
}
function ensureRenameFd(dayKey: string): number | null {
if (currentDayKey === dayKey && currentRenameFd !== null) {
return currentRenameFd;
}
// ensureDayFile handles day transitions
if (currentDayKey !== dayKey) {
ensureDayFile(dayKey);
}
if (currentRenameFd !== null) {
return currentRenameFd;
}
const monthDir = path.join(dailyLogDir, getMonthDir(dayKey));
try {
fs.mkdirSync(monthDir, { recursive: true });
const filePath = path.join(monthDir, `${dayKey}-rename.log`);
currentRenameFd = fs.openSync(filePath, "a");
return currentRenameFd;
} catch {
return null;
}
}
function writeToDailyLog(line: string): void {
if (!dailyLogDir) return;
const dayKey = getDayKey();
const fd = ensureDayFile(dayKey);
if (fd === null) return;
try {
fs.writeSync(fd, line);
} catch {
// Close and retry on next write
try { fs.closeSync(fd); } catch { /* ignore */ }
currentLogFd = null;
}
}
export function writeToDailyRenameLog(line: string): void {
if (!dailyLogDir) return;
const dayKey = getDayKey();
const fd = ensureRenameFd(dayKey);
if (fd === null) return;
try {
fs.writeSync(fd, line);
} catch {
try { fs.closeSync(fd); } catch { /* ignore */ }
currentRenameFd = null;
}
}
function cleanupOldDailyLogs(): void {
if (!dailyLogDir) return;
const now = Date.now();
if (now - lastCleanupAt < CLEANUP_CHECK_INTERVAL_MS) return;
lastCleanupAt = now;
const cutoffMs = now - DAILY_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
try {
const monthDirs = fs.readdirSync(dailyLogDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d{4}-\d{2}$/.test(e.name));
for (const monthDir of monthDirs) {
const monthPath = path.join(dailyLogDir, monthDir.name);
const files = fs.readdirSync(monthPath, { withFileTypes: true })
.filter((e) => e.isFile() && /^\d{4}-\d{2}-\d{2}/.test(e.name));
for (const file of files) {
const filePath = path.join(monthPath, file.name);
try {
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoffMs) {
fs.rmSync(filePath, { force: true });
}
} catch { /* ignore */ }
}
// Remove empty month dirs
try {
const remaining = fs.readdirSync(monthPath);
if (remaining.length === 0) {
fs.rmdirSync(monthPath);
}
} catch { /* ignore */ }
}
} catch {
// ignore cleanup errors
}
}
export function initDailyLog(baseDir: string): void {
dailyLogDir = path.join(baseDir, "daily-logs");
try {
fs.mkdirSync(dailyLogDir, { recursive: true });
} catch { /* ignore */ }
// Attach listener to main logger
logListener = (line: string) => writeToDailyLog(line);
addLogListener(logListener);
// Initial cleanup
cleanupOldDailyLogs();
// Periodic cleanup
cleanupTimer = setInterval(cleanupOldDailyLogs, CLEANUP_CHECK_INTERVAL_MS);
if (cleanupTimer.unref) cleanupTimer.unref();
}
export function shutdownDailyLog(): void {
if (logListener) {
removeLogListener(logListener);
logListener = null;
}
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
if (currentLogFd !== null) {
try { fs.closeSync(currentLogFd); } catch { /* ignore */ }
currentLogFd = null;
}
if (currentRenameFd !== null) {
try { fs.closeSync(currentRenameFd); } catch { /* ignore */ }
currentRenameFd = null;
}
currentDayKey = "";
}
export function getDailyLogDir(): string {
return dailyLogDir;
}

View File

@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { writeToDailyRenameLog } from "./daily-log";
type RenameLogLevel = "INFO" | "WARN" | "ERROR"; type RenameLogLevel = "INFO" | "WARN" | "ERROR";
@ -93,11 +94,9 @@ export function logRenameEvent(level: RenameLogLevel, message: string, fields?:
if (!fs.existsSync(renameLogPath)) { if (!fs.existsSync(renameLogPath)) {
fs.writeFileSync(renameLogPath, "", "utf8"); fs.writeFileSync(renameLogPath, "", "utf8");
} }
fs.appendFileSync( const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
renameLogPath, fs.appendFileSync(renameLogPath, line, "utf8");
`${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`, writeToDailyRenameLog(line);
"utf8"
);
} catch { } catch {
// ignore write errors // ignore write errors
} }