diff --git a/package.json b/package.json index d6bcdbb..e762401 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.5.78", + "version": "1.5.79", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index c7f41d2..318763d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -19,6 +19,7 @@ import { APP_VERSION } from "./constants"; import { DownloadManager } from "./download-manager"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; +import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; @@ -52,6 +53,7 @@ export class AppController { public constructor() { configureLogger(this.storagePaths.baseDir); + initSessionLog(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); const session = loadSession(this.storagePaths); this.megaWebFallback = new MegaWebFallback(() => ({ @@ -224,6 +226,10 @@ export class AppController { this.manager.extractNow(packageId); } + public resetPackage(packageId: string): void { + this.manager.resetPackage(packageId); + } + public cancelPackage(packageId: string): void { this.manager.cancelPackage(packageId); } @@ -281,11 +287,16 @@ export class AppController { return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; } + public getSessionLogPath(): string | null { + return getSessionLogPath(); + } + public shutdown(): void { stopDebugServer(); abortActiveUpdateDownload(); this.manager.prepareForShutdown(); this.megaWebFallback.dispose(); + shutdownSessionLog(); logger.info("App beendet"); } diff --git a/src/main/logger.ts b/src/main/logger.ts index 5978439..dde812e 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -8,12 +8,19 @@ const LOG_BUFFER_LIMIT_CHARS = 1_000_000; const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024; const rotateCheckAtByFile = new Map(); +type LogListener = (line: string) => void; +let logListener: LogListener | null = null; + let pendingLines: string[] = []; let pendingChars = 0; let flushTimer: NodeJS.Timeout | null = null; let flushInFlight = false; let exitHookAttached = false; +export function setLogListener(listener: LogListener | null): void { + logListener = listener; +} + export function configureLogger(baseDir: string): void { logFilePath = path.join(baseDir, "rd_downloader.log"); const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log"); @@ -188,6 +195,10 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void { pendingLines.push(line); pendingChars += line.length; + if (logListener) { + try { logListener(line); } catch { /* ignore */ } + } + while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { const removed = pendingLines.shift(); if (!removed) { diff --git a/src/main/main.ts b/src/main/main.ts index 9b948f8..69f7699 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -326,6 +326,10 @@ function registerIpcHandlers(): void { validateString(packageId, "packageId"); return controller.extractNow(packageId); }); + ipcMain.handle(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.resetPackage(packageId); + }); ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { @@ -396,6 +400,13 @@ function registerIpcHandlers(): void { await shell.openPath(logPath); }); + ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { + const logPath = controller.getSessionLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { const options = { properties: ["openFile"] as Array<"openFile">, diff --git a/src/main/session-log.ts b/src/main/session-log.ts new file mode 100644 index 0000000..f28c47a --- /dev/null +++ b/src/main/session-log.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import path from "node:path"; +import { setLogListener } from "./logger"; + +const SESSION_LOG_FLUSH_INTERVAL_MS = 200; + +let sessionLogPath: string | null = null; +let sessionLogsDir: string | null = null; +let pendingLines: string[] = []; +let flushTimer: NodeJS.Timeout | null = null; + +function formatTimestamp(): string { + const now = new Date(); + const y = now.getFullYear(); + const mo = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + const h = String(now.getHours()).padStart(2, "0"); + const mi = String(now.getMinutes()).padStart(2, "0"); + const s = String(now.getSeconds()).padStart(2, "0"); + return `${y}-${mo}-${d}_${h}-${mi}-${s}`; +} + +function flushPending(): void { + if (pendingLines.length === 0 || !sessionLogPath) { + return; + } + const chunk = pendingLines.join(""); + pendingLines = []; + try { + fs.appendFileSync(sessionLogPath, chunk, "utf8"); + } catch { + // ignore write errors + } +} + +function scheduleFlush(): void { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushPending(); + }, SESSION_LOG_FLUSH_INTERVAL_MS); +} + +function appendToSessionLog(line: string): void { + if (!sessionLogPath) { + return; + } + pendingLines.push(line); + scheduleFlush(); +} + +async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise { + try { + const files = await fs.promises.readdir(dir); + const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; + for (const file of files) { + if (!file.startsWith("session_") || !file.endsWith(".txt")) { + continue; + } + const filePath = path.join(dir, file); + try { + const stat = await fs.promises.stat(filePath); + if (stat.mtimeMs < cutoff) { + await fs.promises.unlink(filePath); + } + } catch { + // ignore - file may be locked + } + } + } catch { + // ignore - dir may not exist + } +} + +export function initSessionLog(baseDir: string): void { + sessionLogsDir = path.join(baseDir, "session-logs"); + fs.mkdirSync(sessionLogsDir, { recursive: true }); + + const timestamp = formatTimestamp(); + sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`); + + const isoTimestamp = new Date().toISOString(); + try { + fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8"); + } catch { + sessionLogPath = null; + return; + } + + setLogListener((line) => appendToSessionLog(line)); + + void cleanupOldSessionLogs(sessionLogsDir, 7); +} + +export function getSessionLogPath(): string | null { + return sessionLogPath; +} + +export function shutdownSessionLog(): void { + if (!sessionLogPath) { + return; + } + + // Flush any pending lines + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushPending(); + + // Write closing line + const isoTimestamp = new Date().toISOString(); + try { + fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8"); + } catch { + // ignore + } + + setLogListener(null); + sessionLogPath = null; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 3c435a2..064033e 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -49,8 +49,10 @@ const api: ElectronApi = { exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), + openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), + resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 987033f..7dd53f3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1983,6 +1983,9 @@ export function App(): ReactElement { + diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index edaf39c..d7e69dd 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -33,8 +33,10 @@ export const IPC_CHANNELS = { EXPORT_BACKUP: "app:export-backup", IMPORT_BACKUP: "app:import-backup", OPEN_LOG: "app:open-log", + OPEN_SESSION_LOG: "app:open-session-log", RETRY_EXTRACTION: "queue:retry-extraction", EXTRACT_NOW: "queue:extract-now", + RESET_PACKAGE: "queue:reset-package", GET_HISTORY: "history:get", CLEAR_HISTORY: "history:clear", REMOVE_HISTORY_ENTRY: "history:remove-entry" diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index ef8d8d2..cbf9d67 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -44,8 +44,10 @@ export interface ElectronApi { exportBackup: () => Promise<{ saved: boolean }>; importBackup: () => Promise<{ restored: boolean; message: string }>; openLog: () => Promise; + openSessionLog: () => Promise; retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; + resetPackage: (packageId: string) => Promise; getHistory: () => Promise; clearHistory: () => Promise; removeHistoryEntry: (entryId: string) => Promise; diff --git a/tests/session-log.test.ts b/tests/session-log.test.ts new file mode 100644 index 0000000..55175de --- /dev/null +++ b/tests/session-log.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "../src/main/session-log"; +import { setLogListener } from "../src/main/logger"; + +const tempDirs: string[] = []; + +afterEach(() => { + // Ensure listener is cleared between tests + setLogListener(null); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("session-log", () => { + it("initSessionLog creates directory and file", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath(); + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + expect(fs.existsSync(path.join(baseDir, "session-logs"))).toBe(true); + expect(path.basename(logPath!)).toMatch(/^session_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.txt$/); + + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("=== Session gestartet:"); + + shutdownSessionLog(); + }); + + it("logger listener writes to session log", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath()!; + + // Simulate a log line via the listener + const { logger } = await import("../src/main/logger"); + logger.info("Test-Nachricht für Session-Log"); + + // Wait for flush (200ms interval + margin) + await new Promise((resolve) => setTimeout(resolve, 350)); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("Test-Nachricht für Session-Log"); + + shutdownSessionLog(); + }); + + it("shutdownSessionLog writes closing line", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath()!; + + shutdownSessionLog(); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("=== Session beendet:"); + }); + + it("shutdownSessionLog removes listener", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const logPath = getSessionLogPath()!; + + shutdownSessionLog(); + + // Log after shutdown - should NOT appear in session log + const { logger } = await import("../src/main/logger"); + logger.info("Nach-Shutdown-Nachricht"); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).not.toContain("Nach-Shutdown-Nachricht"); + }); + + it("cleanupOldSessionLogs deletes old files", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + const logsDir = path.join(baseDir, "session-logs"); + fs.mkdirSync(logsDir, { recursive: true }); + + // Create a fake old session log + const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt"); + fs.writeFileSync(oldFile, "old session"); + // Set mtime to 30 days ago + const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + fs.utimesSync(oldFile, oldTime, oldTime); + + // Create a recent file + const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt"); + fs.writeFileSync(newFile, "new session"); + + // initSessionLog triggers cleanup + initSessionLog(baseDir); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(fs.existsSync(oldFile)).toBe(false); + expect(fs.existsSync(newFile)).toBe(true); + + shutdownSessionLog(); + }); + + it("cleanupOldSessionLogs keeps recent files", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + const logsDir = path.join(baseDir, "session-logs"); + fs.mkdirSync(logsDir, { recursive: true }); + + // Create a file from 2 days ago (should be kept) + const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt"); + fs.writeFileSync(recentFile, "recent session"); + const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + fs.utimesSync(recentFile, recentTime, recentTime); + + initSessionLog(baseDir); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(fs.existsSync(recentFile)).toBe(true); + + shutdownSessionLog(); + }); + + it("multiple sessions create different files", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-")); + tempDirs.push(baseDir); + + initSessionLog(baseDir); + const path1 = getSessionLogPath(); + shutdownSessionLog(); + + // Small delay to ensure different timestamp + const start = Date.now(); + while (Date.now() - start < 1100) { + // busy-wait for 1.1 seconds to get different second in filename + } + + initSessionLog(baseDir); + const path2 = getSessionLogPath(); + shutdownSessionLog(); + + expect(path1).not.toBeNull(); + expect(path2).not.toBeNull(); + expect(path1).not.toBe(path2); + expect(fs.existsSync(path1!)).toBe(true); + expect(fs.existsSync(path2!)).toBe(true); + }); +});