From 56ce7c2aea4411179c926415783262d888669ed3 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 9 Mar 2026 01:21:11 +0100 Subject: [PATCH] Add remote host and item diagnostics --- README.md | 48 +++ src/main/app-controller.ts | 7 + src/main/debug-server.ts | 428 ++++++++++++++++++++++----- src/main/download-manager.ts | 30 ++ src/main/item-log.ts | 221 ++++++++++++++ src/main/main.ts | 8 + src/main/windows-host-diagnostics.ts | 322 ++++++++++++++++++++ src/preload/preload.ts | 1 + src/renderer/App.tsx | 9 + src/shared/ipc.ts | 1 + src/shared/preload-api.ts | 1 + tests/debug-server.test.ts | 324 ++++++++++++++++++++ tests/item-log.test.ts | 66 +++++ 13 files changed, 1387 insertions(+), 79 deletions(-) create mode 100644 src/main/item-log.ts create mode 100644 src/main/windows-host-diagnostics.ts create mode 100644 tests/debug-server.test.ts create mode 100644 tests/item-log.test.ts diff --git a/README.md b/README.md index f20ee3f..dac32f7 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,54 @@ Runtime files are stored in Electron's `userData` directory, including: - `rd_session_state.json` - `rd_history.json` - `rd_downloader.log` +- `session-logs/session_*.txt` +- `package-logs/package_*.txt` +- `item-logs/item_*.txt` + +### Remote debug server + +For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails. + +Enable it by creating these files in the same runtime folder that contains `rd_downloader.log`: + +- `debug_token.txt` + Example: a long random token such as `rd-debug-please-change-me` +- `debug_port.txt` + Example: `9868` +- `debug_host.txt` (optional) + Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy. + +Available endpoints after restart: + +- `GET /health` +- `GET /meta` +- `GET /host/diagnostics` +- `GET /status` +- `GET /packages?package=Release&includeItems=1` +- `GET /items?status=downloading&package=Release` +- `GET /session?package=Release` +- `GET /log?lines=100&grep=keyword` +- `GET /logs/main?lines=100&grep=keyword` +- `GET /logs/session?lines=100&grep=keyword` +- `GET /logs/package?package=Release&lines=100&grep=keyword` +- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword` +- `GET /diagnostics?package=Release&lines=150` + +Authentication works with either: + +- header: `Authorization: Bearer ` +- query param: `?token=` + +Example from PowerShell: + +```powershell +Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release" +Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200" +Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200" +Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" +``` + +This makes it easy to share one URL plus token during support, so current package status, session state, package/session logs, and host-side Windows crash hints can be inspected remotely. ## Troubleshooting diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 96266e8..e8d424e 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -28,6 +28,7 @@ import { configureLogger, getLogFilePath, logger } from "./logger"; import { AllDebridWebFallback } from "./all-debrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web"; import { RealDebridWebFallback } from "./realdebrid-web"; +import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; @@ -72,6 +73,7 @@ export class AppController { configureLogger(this.storagePaths.baseDir); initSessionLog(this.storagePaths.baseDir); initPackageLogs(this.storagePaths.baseDir); + initItemLogs(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); const session = loadSession(this.storagePaths); this.megaWebFallback = new MegaWebFallback(() => ({ @@ -484,6 +486,10 @@ export class AppController { return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId); } + public getItemLogPath(itemId: string): string | null { + return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); + } + public shutdown(): void { stopDebugServer(); abortActiveUpdateDownload(); @@ -494,6 +500,7 @@ export class AppController { this.bestDebridWebFallback.dispose(); shutdownSessionLog(); shutdownPackageLogs(); + shutdownItemLogs(); logger.info("App beendet"); } diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 204ed7e..fbb38d6 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -1,15 +1,25 @@ import http from "node:http"; import fs from "node:fs"; import path from "node:path"; +import { APP_VERSION } from "./constants"; import { logger, getLogFilePath } from "./logger"; +import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; +import { getSessionLogPath } from "./session-log"; +import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; +import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; +import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; const DEFAULT_PORT = 9868; +const DEFAULT_HOST = "127.0.0.1"; const MAX_LOG_LINES = 10000; let server: http.Server | null = null; let manager: DownloadManager | null = null; let authToken = ""; +let bindHost = DEFAULT_HOST; +let bindPort = DEFAULT_PORT; +let runtimeBaseDir = ""; function loadToken(baseDir: string): string { const tokenPath = path.join(baseDir, "debug_token.txt"); @@ -33,6 +43,25 @@ function getPort(baseDir: string): number { return DEFAULT_PORT; } +function getHost(baseDir: string): string { + const hostPath = path.join(baseDir, "debug_host.txt"); + try { + const raw = fs.readFileSync(hostPath, "utf8").trim(); + if (!raw) { + return DEFAULT_HOST; + } + if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) { + return raw; + } + if (/^[a-z0-9.-]+$/i.test(raw)) { + return raw; + } + } catch { + // ignore + } + return DEFAULT_HOST; +} + function checkAuth(req: http.IncomingMessage): boolean { if (!authToken) { return false; @@ -55,10 +84,20 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown): res.end(body); } -function readLogTail(lines: number): string[] { - const logPath = getLogFilePath(); +function normalizeLinesParam(rawValue: string | null, fallback: number): number { + const parsed = Number(rawValue || String(fallback)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.max(1, Math.min(Math.floor(parsed), MAX_LOG_LINES)); +} + +function readLogTailFromFile(filePath: string | null, lines: number): string[] { + if (!filePath) { + return ["(Log-Datei nicht gefunden)"]; + } try { - const content = fs.readFileSync(logPath, "utf8"); + const content = fs.readFileSync(filePath, "utf8"); const allLines = content.split("\n").filter((l) => l.trim().length > 0); return allLines.slice(-Math.min(lines, MAX_LOG_LINES)); } catch { @@ -66,11 +105,134 @@ function readLogTail(lines: number): string[] { } } +function filterLines(lines: string[], grep: string): string[] { + const pattern = String(grep || "").trim().toLowerCase(); + if (!pattern) { + return lines; + } + return lines.filter((line) => line.toLowerCase().includes(pattern)); +} + +function summarizeItem(item: DownloadItem): Record { + return { + id: item.id, + packageId: item.packageId, + fileName: item.fileName, + status: item.status, + fullStatus: item.fullStatus, + provider: item.provider, + providerLabel: item.providerLabel || "", + progress: item.progressPercent, + speedMBs: +(item.speedBps / 1024 / 1024).toFixed(2), + downloadedMB: +(item.downloadedBytes / 1024 / 1024).toFixed(1), + totalMB: item.totalBytes ? +(item.totalBytes / 1024 / 1024).toFixed(1) : null, + retries: item.retries, + lastError: item.lastError, + targetPath: item.targetPath, + updatedAt: item.updatedAt + }; +} + +function summarizePackage(snapshot: UiSnapshot, pkg: PackageEntry, includeItems: boolean): Record { + const ids = new Set(pkg.itemIds); + const packageItems = Object.values(snapshot.session.items).filter((item) => ids.has(item.id)); + const byStatus: Record = {}; + for (const item of packageItems) { + byStatus[item.status] = (byStatus[item.status] || 0) + 1; + } + return { + id: pkg.id, + name: pkg.name, + status: pkg.status, + enabled: pkg.enabled, + cancelled: pkg.cancelled, + outputDir: pkg.outputDir, + extractDir: pkg.extractDir, + postProcessLabel: pkg.postProcessLabel || "", + itemCount: pkg.itemIds.length, + itemCounts: byStatus, + updatedAt: pkg.updatedAt, + items: includeItems ? packageItems.map((item) => summarizeItem(item)) : undefined + }; +} + +function findPackage(snapshot: UiSnapshot, query: string): PackageEntry | null { + const needle = String(query || "").trim().toLowerCase(); + if (!needle) { + return null; + } + return Object.values(snapshot.session.packages).find((pkg) => + pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle) + ) || null; +} + +function findItem(snapshot: UiSnapshot, query: string): DownloadItem | null { + const needle = String(query || "").trim().toLowerCase(); + if (!needle) { + return null; + } + return Object.values(snapshot.session.items).find((item) => + item.id.toLowerCase() === needle || item.fileName.toLowerCase().includes(needle) + ) || null; +} + +function getPackageLogPathForQuery(snapshot: UiSnapshot, query: string): { pkg: PackageEntry | null; logPath: string | null } { + const pkg = findPackage(snapshot, query); + if (pkg) { + const livePath = manager?.getPackageLogPath(pkg.id) || null; + return { pkg, logPath: livePath || getPersistedPackageLogPath(pkg.id) }; + } + const directPath = getPersistedPackageLogPath(String(query || "").trim()); + return { pkg: null, logPath: directPath }; +} + +function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: DownloadItem | null; logPath: string | null } { + const item = findItem(snapshot, query); + if (item) { + const livePath = manager?.getItemLogPath(item.id) || null; + return { item, logPath: livePath || getPersistedItemLogPath(item.id) }; + } + const directPath = getPersistedItemLogPath(String(query || "").trim()); + return { item: null, logPath: directPath }; +} + +function buildStatusPayload(snapshot: UiSnapshot): Record { + const items = Object.values(snapshot.session.items); + const packages = Object.values(snapshot.session.packages); + + const byStatus: Record = {}; + for (const item of items) { + byStatus[item.status] = (byStatus[item.status] || 0) + 1; + } + + const activeItems = items + .filter((item) => item.status === "downloading" || item.status === "validating") + .map((item) => summarizeItem(item)); + + const failedItems = items + .filter((item) => item.status === "failed") + .map((item) => summarizeItem(item)); + + return { + running: snapshot.session.running, + paused: snapshot.session.paused, + speed: snapshot.speedText, + eta: snapshot.etaText, + itemCounts: byStatus, + totalItems: items.length, + totalPackages: packages.length, + packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)), + activeItems, + failedItems: failedItems.length > 0 ? failedItems : undefined + }; +} + function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Authorization" + "Access-Control-Allow-Headers": "Authorization", + "Access-Control-Allow-Methods": "GET,OPTIONS" }); res.end(); return; @@ -87,77 +249,144 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi if (pathname === "/health") { jsonResponse(res, 200, { status: "ok", + appVersion: APP_VERSION, uptime: Math.floor(process.uptime()), memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024) }); return; } - if (pathname === "/log") { - const count = Math.min(Number(url.searchParams.get("lines") || "100"), MAX_LOG_LINES); + if (pathname === "/meta") { + jsonResponse(res, 200, { + appVersion: APP_VERSION, + runtimeBaseDir, + debugServer: { + host: bindHost, + port: bindPort + }, + logPaths: { + main: getLogFilePath(), + session: getSessionLogPath() + }, + endpoints: [ + "GET /health", + "GET /meta", + "GET /host/diagnostics", + "GET /log?lines=100&grep=keyword", + "GET /logs/main?lines=100&grep=keyword", + "GET /logs/session?lines=100&grep=keyword", + "GET /logs/package?package=Release&lines=100&grep=keyword", + "GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword", + "GET /status", + "GET /packages?package=Release&includeItems=1", + "GET /items?status=downloading&package=Release", + "GET /session?package=Release", + "GET /diagnostics?package=Release&lines=150" + ] + }); + return; + } + + if (pathname === "/host/diagnostics") { + jsonResponse(res, 200, getWindowsHostDiagnostics()); + return; + } + + if (pathname === "/log" || pathname === "/logs/main") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const grep = url.searchParams.get("grep") || ""; - let lines = readLogTail(count); - if (grep) { - const pattern = grep.toLowerCase(); - lines = lines.filter((l) => l.toLowerCase().includes(pattern)); - } + const lines = filterLines(readLogTailFromFile(getLogFilePath(), count), grep); jsonResponse(res, 200, { lines, count: lines.length }); return; } + if (pathname === "/logs/session") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getSessionLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + lines, + count: lines.length + }); + return; + } + + if (pathname === "/logs/package") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const packageQuery = url.searchParams.get("package") || url.searchParams.get("packageId") || ""; + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const resolved = getPackageLogPathForQuery(snapshot, packageQuery); + if (!resolved.logPath) { + jsonResponse(res, 404, { error: "Package log not found", package: packageQuery }); + return; + } + const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep); + jsonResponse(res, 200, { + package: resolved.pkg ? summarizePackage(snapshot, resolved.pkg, false) : undefined, + path: resolved.logPath, + lines, + count: lines.length + }); + return; + } + + if (pathname === "/logs/item") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const itemQuery = url.searchParams.get("item") || url.searchParams.get("itemId") || ""; + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const resolved = getItemLogPathForQuery(snapshot, itemQuery); + if (!resolved.logPath) { + jsonResponse(res, 404, { error: "Item log not found", item: itemQuery }); + return; + } + const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep); + jsonResponse(res, 200, { + item: resolved.item ? summarizeItem(resolved.item) : undefined, + path: resolved.logPath, + lines, + count: lines.length + }); + return; + } + if (pathname === "/status") { if (!manager) { jsonResponse(res, 503, { error: "Manager not initialized" }); return; } const snapshot = manager.getSnapshot(); - const items = Object.values(snapshot.session.items); - const packages = Object.values(snapshot.session.packages); + jsonResponse(res, 200, buildStatusPayload(snapshot)); + return; + } - const byStatus: Record = {}; - for (const item of items) { - byStatus[item.status] = (byStatus[item.status] || 0) + 1; + if (pathname === "/packages") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const packageQuery = url.searchParams.get("package") || ""; + const includeItems = /^(1|true|yes)$/i.test(String(url.searchParams.get("includeItems") || "")); + let packages = Object.values(snapshot.session.packages); + if (packageQuery) { + const needle = packageQuery.toLowerCase(); + packages = packages.filter((pkg) => pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle)); } - - const activeItems = items - .filter((i) => i.status === "downloading" || i.status === "validating") - .map((i) => ({ - id: i.id, - fileName: i.fileName, - status: i.status, - fullStatus: i.fullStatus, - provider: i.provider, - progress: i.progressPercent, - speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2), - downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1), - totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null, - retries: i.retries, - lastError: i.lastError - })); - - const failedItems = items - .filter((i) => i.status === "failed") - .map((i) => ({ - fileName: i.fileName, - lastError: i.lastError, - retries: i.retries, - provider: i.provider - })); - jsonResponse(res, 200, { - running: snapshot.session.running, - paused: snapshot.session.paused, - speed: snapshot.speedText, - eta: snapshot.etaText, - itemCounts: byStatus, - totalItems: items.length, - packages: packages.map((p) => ({ - name: p.name, - status: p.status, - items: p.itemIds.length - })), - activeItems, - failedItems: failedItems.length > 0 ? failedItems : undefined + count: packages.length, + packages: packages.map((pkg) => summarizePackage(snapshot, pkg, includeItems)) }); return; } @@ -175,9 +404,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi items = items.filter((i) => i.status === filter); } if (pkg) { - const pkgLower = pkg.toLowerCase(); - const matchedPkg = Object.values(snapshot.session.packages) - .find((p) => p.name.toLowerCase().includes(pkgLower)); + const matchedPkg = findPackage(snapshot, pkg); if (matchedPkg) { const ids = new Set(matchedPkg.itemIds); items = items.filter((i) => ids.has(i.id)); @@ -185,18 +412,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi } jsonResponse(res, 200, { count: items.length, - items: items.map((i) => ({ - fileName: i.fileName, - status: i.status, - fullStatus: i.fullStatus, - provider: i.provider, - progress: i.progressPercent, - speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2), - downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1), - totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null, - retries: i.retries, - lastError: i.lastError - })) + items: items.map((i) => summarizeItem(i)) }); return; } @@ -209,16 +425,14 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi const snapshot = manager.getSnapshot(); const pkg = url.searchParams.get("package"); if (pkg) { - const pkgLower = pkg.toLowerCase(); - const matchedPkg = Object.values(snapshot.session.packages) - .find((p) => p.name.toLowerCase().includes(pkgLower)); + const matchedPkg = findPackage(snapshot, pkg); if (matchedPkg) { const ids = new Set(matchedPkg.itemIds); const pkgItems = Object.values(snapshot.session.items) .filter((i) => ids.has(i.id)); jsonResponse(res, 200, { - package: matchedPkg, - items: pkgItems + package: summarizePackage(snapshot, matchedPkg, false), + items: pkgItems.map((item) => summarizeItem(item)) }); return; } @@ -238,19 +452,74 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/diagnostics") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const lineCount = normalizeLinesParam(url.searchParams.get("lines"), 150); + const grep = url.searchParams.get("grep") || ""; + const packageQuery = url.searchParams.get("package") || ""; + const mainLogPath = getLogFilePath(); + const sessionLogPath = getSessionLogPath(); + const selectedPackage = packageQuery ? findPackage(snapshot, packageQuery) : null; + const packageLogPath = selectedPackage + ? manager.getPackageLogPath(selectedPackage.id) || getPersistedPackageLogPath(selectedPackage.id) + : null; + jsonResponse(res, 200, { + meta: { + appVersion: APP_VERSION, + serverTime: new Date().toISOString(), + runtimeBaseDir, + debugServer: { + host: bindHost, + port: bindPort + } + }, + status: buildStatusPayload(snapshot), + host: getWindowsHostDiagnostics(), + selectedPackage: selectedPackage ? summarizePackage(snapshot, selectedPackage, true) : undefined, + logs: { + main: { + path: mainLogPath, + lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep) + }, + session: { + path: sessionLogPath, + lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) + }, + package: selectedPackage ? { + path: packageLogPath, + lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep) + } : undefined + } + }); + return; + } + jsonResponse(res, 404, { error: "Not found", endpoints: [ "GET /health", + "GET /meta", + "GET /host/diagnostics", "GET /log?lines=100&grep=keyword", + "GET /logs/main?lines=100&grep=keyword", + "GET /logs/session?lines=100&grep=keyword", + "GET /logs/package?package=Release&lines=100&grep=keyword", + "GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword", "GET /status", + "GET /packages?package=Release&includeItems=1", "GET /items?status=downloading&package=Bloodline", - "GET /session?package=Criminal" + "GET /session?package=Criminal", + "GET /diagnostics?package=Criminal&lines=150" ] }); } export function startDebugServer(mgr: DownloadManager, baseDir: string): void { + runtimeBaseDir = baseDir; authToken = loadToken(baseDir); if (!authToken) { logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); @@ -258,11 +527,12 @@ export function startDebugServer(mgr: DownloadManager, baseDir: string): void { } manager = mgr; - const port = getPort(baseDir); + bindPort = getPort(baseDir); + bindHost = getHost(baseDir); server = http.createServer(handleRequest); - server.listen(port, "127.0.0.1", () => { - logger.info(`Debug-Server gestartet auf Port ${port}`); + server.listen(bindPort, bindHost, () => { + logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`); }); server.on("error", (err) => { logger.warn(`Debug-Server Fehler: ${String(err)}`); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index dd79cea..67f332c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -52,6 +52,7 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; +import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log"; import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage"; import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils"; @@ -1327,6 +1328,14 @@ export class DownloadManager extends EventEmitter { return getPersistedPackageLogPath(packageId); } + public getItemLogPath(itemId: string): string | null { + const item = this.session.items[itemId]; + if (item) { + return this.ensureItemLogForItem(item); + } + return getPersistedItemLogPath(itemId); + } + private ensurePackageLogForPackage(pkg: PackageEntry): string | null { return ensurePackageLog({ packageId: pkg.id, @@ -1336,6 +1345,17 @@ export class DownloadManager extends EventEmitter { }); } + private ensureItemLogForItem(item: DownloadItem): string | null { + const pkg = this.session.packages[item.packageId]; + return ensureItemLog({ + itemId: item.id, + packageId: item.packageId, + packageName: pkg?.name || "", + fileName: item.fileName, + targetPath: item.targetPath + }); + } + private logPackage(packageId: string, level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { writePackageLogEvent(packageId, level, message, fields); } @@ -1358,6 +1378,7 @@ export class DownloadManager extends EventEmitter { if (pkg) { this.ensurePackageLogForPackage(pkg); } + this.ensureItemLogForItem(item); this.logPackage(item.packageId, level, message, { packageName: pkg?.name || "", itemId: item.id, @@ -1366,6 +1387,15 @@ export class DownloadManager extends EventEmitter { targetPath: item.targetPath, ...fields }); + writeItemLogEvent(item.id, level, message, { + packageId: item.packageId, + packageName: pkg?.name || "", + itemId: item.id, + fileName: item.fileName, + status: item.status, + targetPath: item.targetPath, + ...fields + }); } public setSettings(next: AppSettings): void { diff --git a/src/main/item-log.ts b/src/main/item-log.ts new file mode 100644 index 0000000..ca4c5a5 --- /dev/null +++ b/src/main/item-log.ts @@ -0,0 +1,221 @@ +import fs from "node:fs"; +import path from "node:path"; + +const ITEM_LOG_FLUSH_INTERVAL_MS = 200; +const ITEM_LOG_RETENTION_DAYS = 30; + +type ItemLogLevel = "INFO" | "WARN" | "ERROR"; + +export interface ItemLogMeta { + itemId: string; + packageId: string; + packageName: string; + fileName: string; + targetPath: string; +} + +let itemLogsDir: string | null = null; +const knownLogPaths = new Map(); +const pendingLinesByItem = new Map(); +const initializedThisProcess = new Set(); +let flushTimer: NodeJS.Timeout | null = null; + +function normalizeItemId(itemId: string): string { + return String(itemId || "").trim(); +} + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +function getItemLogFilePath(itemId: string): string | null { + const normalized = normalizeItemId(itemId); + if (!normalized || !itemLogsDir) { + return null; + } + const existing = knownLogPaths.get(normalized); + if (existing) { + return existing; + } + const logPath = path.join(itemLogsDir, `item_${normalized}.txt`); + knownLogPaths.set(normalized, logPath); + return logPath; +} + +function flushPending(): void { + for (const [itemId, lines] of pendingLinesByItem.entries()) { + if (lines.length === 0) { + continue; + } + const logPath = getItemLogFilePath(itemId); + if (!logPath) { + continue; + } + const chunk = lines.join(""); + pendingLinesByItem.set(itemId, []); + try { + fs.appendFileSync(logPath, chunk, "utf8"); + } catch { + // ignore write errors + } + } +} + +function scheduleFlush(): void { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushPending(); + }, ITEM_LOG_FLUSH_INTERVAL_MS); +} + +async function cleanupOldItemLogs(dir: string): Promise { + try { + const files = await fs.promises.readdir(dir); + const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + for (const file of files) { + if (!file.startsWith("item_") || !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 locked/missing files + } + } + } catch { + // ignore missing dir + } +} + +function appendLine(itemId: string, line: string): void { + const normalized = normalizeItemId(itemId); + if (!normalized) { + return; + } + const lines = pendingLinesByItem.get(normalized) || []; + lines.push(line); + pendingLinesByItem.set(normalized, lines); + scheduleFlush(); +} + +export function initItemLogs(baseDir: string): void { + itemLogsDir = path.join(baseDir, "item-logs"); + try { + fs.mkdirSync(itemLogsDir, { recursive: true }); + } catch { + itemLogsDir = null; + return; + } + void cleanupOldItemLogs(itemLogsDir); +} + +export function ensureItemLog(meta: ItemLogMeta): string | null { + const itemId = normalizeItemId(meta.itemId); + const logPath = getItemLogFilePath(itemId); + if (!logPath) { + return null; + } + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + if (!fs.existsSync(logPath)) { + fs.writeFileSync(logPath, "", "utf8"); + } + if (!initializedThisProcess.has(itemId)) { + initializedThisProcess.add(itemId); + const startedAt = new Date().toISOString(); + fs.appendFileSync( + logPath, + `=== Item-Log Start: ${startedAt} | itemId=${itemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`, + "utf8" + ); + fs.appendFileSync( + logPath, + `${new Date().toISOString()} [INFO] Item-Kontext initialisiert${formatFields({ + packageId: meta.packageId, + packageName: meta.packageName, + fileName: meta.fileName, + targetPath: meta.targetPath + })}\n`, + "utf8" + ); + } + } catch { + return null; + } + return logPath; +} + +export function logItemEvent( + itemId: string, + level: ItemLogLevel, + message: string, + fields?: Record +): void { + const logPath = getItemLogFilePath(itemId); + if (!logPath) { + return; + } + const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`; + appendLine(itemId, line); +} + +export function getItemLogPath(itemId: string): string | null { + const logPath = getItemLogFilePath(itemId); + if (!logPath) { + return null; + } + return fs.existsSync(logPath) ? logPath : null; +} + +export function shutdownItemLogs(): void { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushPending(); + for (const itemId of knownLogPaths.keys()) { + const logPath = getItemLogFilePath(itemId); + if (!logPath) { + continue; + } + try { + fs.appendFileSync(logPath, `=== Item-Log Ende: ${new Date().toISOString()} ===\n`, "utf8"); + } catch { + // ignore + } + } + pendingLinesByItem.clear(); + knownLogPaths.clear(); + initializedThisProcess.clear(); + itemLogsDir = null; +} diff --git a/src/main/main.ts b/src/main/main.ts index 41e081d..17403bb 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -510,6 +510,14 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { + validateString(itemId, "itemId"); + const logPath = controller.getItemLogPath(itemId); + if (logPath) { + await shell.openPath(logPath); + } + }); + ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { await controller.openRealDebridLoginWindow(); }); diff --git a/src/main/windows-host-diagnostics.ts b/src/main/windows-host-diagnostics.ts new file mode 100644 index 0000000..c8883e2 --- /dev/null +++ b/src/main/windows-host-diagnostics.ts @@ -0,0 +1,322 @@ +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; + +export interface WindowsHostEvent { + timeCreated: string; + id: number; + providerName: string; + levelDisplayName: string; + message: string; + bugcheckCode?: string; + bugcheckCodeHex?: string; + reportId?: string; +} + +export interface WindowsHostDumpFile { + name: string; + fullName: string; + length: number; + lastWriteTime: string; +} + +export interface WindowsCrashControlInfo { + crashDumpEnabled: number | null; + minidumpDir: string; + dumpFile: string; + overwrite: number | null; + logEvent: number | null; + autoReboot: number | null; +} + +export interface WindowsHostDiagnostics { + collectedAt: string; + supported: boolean; + platform: string; + crashControl: WindowsCrashControlInfo | null; + recentKernelPower: WindowsHostEvent[]; + recentWerKernel: WindowsHostEvent[]; + recentKernelDump: WindowsHostEvent[]; + recentAppCrashes: WindowsHostEvent[]; + recentMinidumps: WindowsHostDumpFile[]; + assessmentHints: string[]; + errors: string[]; +} + +const CACHE_TTL_MS = 15_000; + +let cachedAt = 0; +let cachedValue: WindowsHostDiagnostics | null = null; + +function createEmptyDiagnostics(): WindowsHostDiagnostics { + return { + collectedAt: new Date().toISOString(), + supported: process.platform === "win32", + platform: process.platform, + crashControl: null, + recentKernelPower: [], + recentWerKernel: [], + recentKernelDump: [], + recentAppCrashes: [], + recentMinidumps: [], + assessmentHints: [], + errors: [] + }; +} + +function runPowerShellJson(script: string): unknown { + const result = spawnSync( + process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], + { + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"] + } + ); + + if (result.status !== 0) { + const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`; + throw new Error(errorText); + } + + const text = String(result.stdout || "").trim(); + if (!text) { + return null; + } + return JSON.parse(text) as unknown; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function asNumber(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeEvent(value: unknown): WindowsHostEvent | null { + const record = asRecord(value); + if (!record) { + return null; + } + return { + timeCreated: asString(record.TimeCreated), + id: asNumber(record.Id) || 0, + providerName: asString(record.ProviderName), + levelDisplayName: asString(record.LevelDisplayName), + message: asString(record.Message), + bugcheckCode: asString(record.BugcheckCode), + bugcheckCodeHex: asString(record.BugcheckCodeHex), + reportId: asString(record.ReportId) + }; +} + +function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null { + const record = asRecord(value); + if (!record) { + return null; + } + return { + name: asString(record.Name), + fullName: asString(record.FullName), + length: asNumber(record.Length) || 0, + lastWriteTime: asString(record.LastWriteTime) + }; +} + +function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null { + const record = asRecord(value); + if (!record) { + return null; + } + return { + crashDumpEnabled: asNumber(record.CrashDumpEnabled), + minidumpDir: asString(record.MinidumpDir), + dumpFile: asString(record.DumpFile), + overwrite: asNumber(record.Overwrite), + logEvent: asNumber(record.LogEvent), + autoReboot: asNumber(record.AutoReboot) + }; +} + +function pushHints(diagnostics: WindowsHostDiagnostics): void { + if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) { + diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin."); + } + if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) { + diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash."); + } + if (diagnostics.recentAppCrashes.length === 0) { + diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden."); + } + if (diagnostics.recentMinidumps.length === 0) { + diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf."); + } +} + +function loadFromPowerShell(): WindowsHostDiagnostics { + const script = String.raw` +$ErrorActionPreference = "SilentlyContinue" + +function Convert-EventRecord($eventRecord) { + $map = @{} + try { + [xml]$xml = $eventRecord.ToXml() + foreach ($node in $xml.Event.EventData.Data) { + if ($node.Name) { + $map[$node.Name] = [string]$node.'#text' + } + } + } catch { + } + + $reportId = "" + if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") { + $reportId = $Matches[1] + } + + [PSCustomObject]@{ + TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" } + Id = [int]$eventRecord.Id + ProviderName = [string]$eventRecord.ProviderName + LevelDisplayName = [string]$eventRecord.LevelDisplayName + Message = [string]$eventRecord.Message + BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" } + BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" } + ReportId = $reportId + } +} + +$startTime = (Get-Date).AddDays(-7) +$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl" + +$kernelPower = @( + Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 | + ForEach-Object { Convert-EventRecord $_ } +) + +$werKernel = @( + Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 | + Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } | + Select-Object -First 10 | + ForEach-Object { Convert-EventRecord $_ } +) + +$kernelDump = @( + Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 | + Select-Object -First 10 | + ForEach-Object { Convert-EventRecord $_ } +) + +$appCrashes = @( + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 | + Where-Object { + ($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and + ($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js") + } | + Select-Object -First 10 | + ForEach-Object { Convert-EventRecord $_ } +) + +$dumpFiles = @() +foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) { + if (Test-Path $dir) { + $dumpFiles += Get-ChildItem -Path $dir -File | + Sort-Object LastWriteTime -Descending | + Select-Object -First 10 | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + FullName = $_.FullName + Length = [int64]$_.Length + LastWriteTime = $_.LastWriteTimeUtc.ToString("o") + } + } + } +} + +[PSCustomObject]@{ + CrashControl = [PSCustomObject]@{ + CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null } + MinidumpDir = [string]$crashControl.MinidumpDir + DumpFile = [string]$crashControl.DumpFile + Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null } + LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null } + AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null } + } + RecentKernelPower = @($kernelPower) + RecentWerKernel = @($werKernel) + RecentKernelDump = @($kernelDump) + RecentAppCrashes = @($appCrashes) + RecentMinidumps = @($dumpFiles) +} | ConvertTo-Json -Depth 6 -Compress +`; + + const raw = runPowerShellJson(script); + const parsed = asRecord(raw); + const diagnostics = createEmptyDiagnostics(); + diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null); + diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : []; + diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : []; + diagnostics.collectedAt = new Date().toISOString(); + pushHints(diagnostics); + return diagnostics; +} + +export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics { + if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) { + return cachedValue; + } + + const diagnostics = createEmptyDiagnostics(); + if (process.platform !== "win32") { + diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar."); + cachedAt = Date.now(); + cachedValue = diagnostics; + return diagnostics; + } + + try { + const loaded = loadFromPowerShell(); + cachedAt = Date.now(); + cachedValue = loaded; + return loaded; + } catch (error) { + diagnostics.errors.push(String(error instanceof Error ? error.message : error)); + diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden."); + cachedAt = Date.now(); + cachedValue = diagnostics; + return diagnostics; + } +} + +export function resetWindowsHostDiagnosticsCache(): void { + cachedAt = 0; + cachedValue = null; +} + +export function hasRecentWindowsMinidumps(): boolean { + for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + if (entries.some((entry) => entry.isFile())) { + return true; + } + } catch { + // ignore + } + } + return false; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 84b78f0..71d8624 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -59,6 +59,7 @@ const api: ElectronApi = { openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), openPackageLog: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), + openItemLog: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId), openRealDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9edf469..0835a8c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5181,6 +5181,15 @@ export function App(): ReactElement { setContextMenu(null); }}>Log öffnen{multi ? ` (${selectedPackageIds.length})` : ""} )} + {contextMenu.itemId && ( + + )}
{hasPackages && !contextMenu.itemId && (