Add remote host and item diagnostics
This commit is contained in:
parent
90a06d2926
commit
56ce7c2aea
48
README.md
48
README.md
@ -181,6 +181,54 @@ Runtime files are stored in Electron's `userData` directory, including:
|
|||||||
- `rd_session_state.json`
|
- `rd_session_state.json`
|
||||||
- `rd_history.json`
|
- `rd_history.json`
|
||||||
- `rd_downloader.log`
|
- `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 <token>`
|
||||||
|
- query param: `?token=<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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ 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 { 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";
|
||||||
@ -72,6 +73,7 @@ export class AppController {
|
|||||||
configureLogger(this.storagePaths.baseDir);
|
configureLogger(this.storagePaths.baseDir);
|
||||||
initSessionLog(this.storagePaths.baseDir);
|
initSessionLog(this.storagePaths.baseDir);
|
||||||
initPackageLogs(this.storagePaths.baseDir);
|
initPackageLogs(this.storagePaths.baseDir);
|
||||||
|
initItemLogs(this.storagePaths.baseDir);
|
||||||
this.settings = loadSettings(this.storagePaths);
|
this.settings = loadSettings(this.storagePaths);
|
||||||
const session = loadSession(this.storagePaths);
|
const session = loadSession(this.storagePaths);
|
||||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||||
@ -484,6 +486,10 @@ export class AppController {
|
|||||||
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
|
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getItemLogPath(itemId: string): string | null {
|
||||||
|
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
public shutdown(): void {
|
public shutdown(): void {
|
||||||
stopDebugServer();
|
stopDebugServer();
|
||||||
abortActiveUpdateDownload();
|
abortActiveUpdateDownload();
|
||||||
@ -494,6 +500,7 @@ export class AppController {
|
|||||||
this.bestDebridWebFallback.dispose();
|
this.bestDebridWebFallback.dispose();
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
shutdownPackageLogs();
|
shutdownPackageLogs();
|
||||||
|
shutdownItemLogs();
|
||||||
logger.info("App beendet");
|
logger.info("App beendet");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,25 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { APP_VERSION } from "./constants";
|
||||||
import { logger, getLogFilePath } from "./logger";
|
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 { DownloadManager } from "./download-manager";
|
||||||
|
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
||||||
|
|
||||||
const DEFAULT_PORT = 9868;
|
const DEFAULT_PORT = 9868;
|
||||||
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
const MAX_LOG_LINES = 10000;
|
const MAX_LOG_LINES = 10000;
|
||||||
|
|
||||||
let server: http.Server | null = null;
|
let server: http.Server | null = null;
|
||||||
let manager: DownloadManager | null = null;
|
let manager: DownloadManager | null = null;
|
||||||
let authToken = "";
|
let authToken = "";
|
||||||
|
let bindHost = DEFAULT_HOST;
|
||||||
|
let bindPort = DEFAULT_PORT;
|
||||||
|
let runtimeBaseDir = "";
|
||||||
|
|
||||||
function loadToken(baseDir: string): string {
|
function loadToken(baseDir: string): string {
|
||||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||||
@ -33,6 +43,25 @@ function getPort(baseDir: string): number {
|
|||||||
return DEFAULT_PORT;
|
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 {
|
function checkAuth(req: http.IncomingMessage): boolean {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return false;
|
return false;
|
||||||
@ -55,10 +84,20 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown):
|
|||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLogTail(lines: number): string[] {
|
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
|
||||||
const logPath = getLogFilePath();
|
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 {
|
try {
|
||||||
const content = fs.readFileSync(logPath, "utf8");
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
const allLines = content.split("\n").filter((l) => l.trim().length > 0);
|
const allLines = content.split("\n").filter((l) => l.trim().length > 0);
|
||||||
return allLines.slice(-Math.min(lines, MAX_LOG_LINES));
|
return allLines.slice(-Math.min(lines, MAX_LOG_LINES));
|
||||||
} catch {
|
} 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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
const ids = new Set(pkg.itemIds);
|
||||||
|
const packageItems = Object.values(snapshot.session.items).filter((item) => ids.has(item.id));
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
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<string, unknown> {
|
||||||
|
const items = Object.values(snapshot.session.items);
|
||||||
|
const packages = Object.values(snapshot.session.packages);
|
||||||
|
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
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 {
|
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204, {
|
res.writeHead(204, {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers": "Authorization"
|
"Access-Control-Allow-Headers": "Authorization",
|
||||||
|
"Access-Control-Allow-Methods": "GET,OPTIONS"
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
@ -87,77 +249,144 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
if (pathname === "/health") {
|
if (pathname === "/health") {
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
|
appVersion: APP_VERSION,
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/log") {
|
if (pathname === "/meta") {
|
||||||
const count = Math.min(Number(url.searchParams.get("lines") || "100"), MAX_LOG_LINES);
|
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") || "";
|
const grep = url.searchParams.get("grep") || "";
|
||||||
let lines = readLogTail(count);
|
const lines = filterLines(readLogTailFromFile(getLogFilePath(), count), grep);
|
||||||
if (grep) {
|
|
||||||
const pattern = grep.toLowerCase();
|
|
||||||
lines = lines.filter((l) => l.toLowerCase().includes(pattern));
|
|
||||||
}
|
|
||||||
jsonResponse(res, 200, { lines, count: lines.length });
|
jsonResponse(res, 200, { lines, count: lines.length });
|
||||||
return;
|
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 (pathname === "/status") {
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const items = Object.values(snapshot.session.items);
|
jsonResponse(res, 200, buildStatusPayload(snapshot));
|
||||||
const packages = Object.values(snapshot.session.packages);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const byStatus: Record<string, number> = {};
|
if (pathname === "/packages") {
|
||||||
for (const item of items) {
|
if (!manager) {
|
||||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
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, {
|
jsonResponse(res, 200, {
|
||||||
running: snapshot.session.running,
|
count: packages.length,
|
||||||
paused: snapshot.session.paused,
|
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, includeItems))
|
||||||
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
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -175,9 +404,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
items = items.filter((i) => i.status === filter);
|
items = items.filter((i) => i.status === filter);
|
||||||
}
|
}
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
const pkgLower = pkg.toLowerCase();
|
const matchedPkg = findPackage(snapshot, pkg);
|
||||||
const matchedPkg = Object.values(snapshot.session.packages)
|
|
||||||
.find((p) => p.name.toLowerCase().includes(pkgLower));
|
|
||||||
if (matchedPkg) {
|
if (matchedPkg) {
|
||||||
const ids = new Set(matchedPkg.itemIds);
|
const ids = new Set(matchedPkg.itemIds);
|
||||||
items = items.filter((i) => ids.has(i.id));
|
items = items.filter((i) => ids.has(i.id));
|
||||||
@ -185,18 +412,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
}
|
}
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
count: items.length,
|
count: items.length,
|
||||||
items: items.map((i) => ({
|
items: items.map((i) => summarizeItem(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
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -209,16 +425,14 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const pkg = url.searchParams.get("package");
|
const pkg = url.searchParams.get("package");
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
const pkgLower = pkg.toLowerCase();
|
const matchedPkg = findPackage(snapshot, pkg);
|
||||||
const matchedPkg = Object.values(snapshot.session.packages)
|
|
||||||
.find((p) => p.name.toLowerCase().includes(pkgLower));
|
|
||||||
if (matchedPkg) {
|
if (matchedPkg) {
|
||||||
const ids = new Set(matchedPkg.itemIds);
|
const ids = new Set(matchedPkg.itemIds);
|
||||||
const pkgItems = Object.values(snapshot.session.items)
|
const pkgItems = Object.values(snapshot.session.items)
|
||||||
.filter((i) => ids.has(i.id));
|
.filter((i) => ids.has(i.id));
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
package: matchedPkg,
|
package: summarizePackage(snapshot, matchedPkg, false),
|
||||||
items: pkgItems
|
items: pkgItems.map((item) => summarizeItem(item))
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -238,19 +452,74 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
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, {
|
jsonResponse(res, 404, {
|
||||||
error: "Not found",
|
error: "Not found",
|
||||||
endpoints: [
|
endpoints: [
|
||||||
"GET /health",
|
"GET /health",
|
||||||
|
"GET /meta",
|
||||||
|
"GET /host/diagnostics",
|
||||||
"GET /log?lines=100&grep=keyword",
|
"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 /status",
|
||||||
|
"GET /packages?package=Release&includeItems=1",
|
||||||
"GET /items?status=downloading&package=Bloodline",
|
"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 {
|
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
||||||
|
runtimeBaseDir = baseDir;
|
||||||
authToken = loadToken(baseDir);
|
authToken = loadToken(baseDir);
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
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;
|
manager = mgr;
|
||||||
const port = getPort(baseDir);
|
bindPort = getPort(baseDir);
|
||||||
|
bindHost = getHost(baseDir);
|
||||||
|
|
||||||
server = http.createServer(handleRequest);
|
server = http.createServer(handleRequest);
|
||||||
server.listen(port, "127.0.0.1", () => {
|
server.listen(bindPort, bindHost, () => {
|
||||||
logger.info(`Debug-Server gestartet auf Port ${port}`);
|
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
|
||||||
});
|
});
|
||||||
server.on("error", (err) => {
|
server.on("error", (err) => {
|
||||||
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg
|
|||||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor";
|
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { logger } from "./logger";
|
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 { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
|
||||||
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
|
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
|
||||||
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
|
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
|
||||||
@ -1327,6 +1328,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return getPersistedPackageLogPath(packageId);
|
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 {
|
private ensurePackageLogForPackage(pkg: PackageEntry): string | null {
|
||||||
return ensurePackageLog({
|
return ensurePackageLog({
|
||||||
packageId: pkg.id,
|
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<string, unknown>): void {
|
private logPackage(packageId: string, level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
|
||||||
writePackageLogEvent(packageId, level, message, fields);
|
writePackageLogEvent(packageId, level, message, fields);
|
||||||
}
|
}
|
||||||
@ -1358,6 +1378,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (pkg) {
|
if (pkg) {
|
||||||
this.ensurePackageLogForPackage(pkg);
|
this.ensurePackageLogForPackage(pkg);
|
||||||
}
|
}
|
||||||
|
this.ensureItemLogForItem(item);
|
||||||
this.logPackage(item.packageId, level, message, {
|
this.logPackage(item.packageId, level, message, {
|
||||||
packageName: pkg?.name || "",
|
packageName: pkg?.name || "",
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
@ -1366,6 +1387,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
targetPath: item.targetPath,
|
targetPath: item.targetPath,
|
||||||
...fields
|
...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 {
|
public setSettings(next: AppSettings): void {
|
||||||
|
|||||||
221
src/main/item-log.ts
Normal file
221
src/main/item-log.ts
Normal file
@ -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<string, string>();
|
||||||
|
const pendingLinesByItem = new Map<string, string[]>();
|
||||||
|
const initializedThisProcess = new Set<string>();
|
||||||
|
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, unknown>): 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<void> {
|
||||||
|
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<string, unknown>
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@ -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 () => {
|
ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
|
||||||
await controller.openRealDebridLoginWindow();
|
await controller.openRealDebridLoginWindow();
|
||||||
});
|
});
|
||||||
|
|||||||
322
src/main/windows-host-diagnostics.ts
Normal file
322
src/main/windows-host-diagnostics.ts
Normal file
@ -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<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -59,6 +59,7 @@ const api: ElectronApi = {
|
|||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
||||||
|
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
||||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||||
|
|||||||
@ -5181,6 +5181,15 @@ export function App(): ReactElement {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}}>Log öffnen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
}}>Log öffnen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
||||||
)}
|
)}
|
||||||
|
{contextMenu.itemId && (
|
||||||
|
<button className="ctx-menu-item" onClick={() => {
|
||||||
|
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
||||||
|
for (const id of itemIds) {
|
||||||
|
void window.rd.openItemLog(id).catch(() => {});
|
||||||
|
}
|
||||||
|
setContextMenu(null);
|
||||||
|
}}>Item-Log öffnen{multi ? ` (${selectedItemIds.length})` : ""}</button>
|
||||||
|
)}
|
||||||
<div className="ctx-menu-sep" />
|
<div className="ctx-menu-sep" />
|
||||||
{hasPackages && !contextMenu.itemId && (
|
{hasPackages && !contextMenu.itemId && (
|
||||||
<button className="ctx-menu-item" onClick={() => {
|
<button className="ctx-menu-item" onClick={() => {
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export const IPC_CHANNELS = {
|
|||||||
OPEN_LOG: "app:open-log",
|
OPEN_LOG: "app:open-log",
|
||||||
OPEN_SESSION_LOG: "app:open-session-log",
|
OPEN_SESSION_LOG: "app:open-session-log",
|
||||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||||
|
OPEN_ITEM_LOG: "app:open-item-log",
|
||||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export interface ElectronApi {
|
|||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
openSessionLog: () => Promise<void>;
|
openSessionLog: () => Promise<void>;
|
||||||
openPackageLog: (packageId: string) => Promise<void>;
|
openPackageLog: (packageId: string) => Promise<void>;
|
||||||
|
openItemLog: (itemId: string) => Promise<void>;
|
||||||
openRealDebridLogin: () => Promise<void>;
|
openRealDebridLogin: () => Promise<void>;
|
||||||
openAllDebridLogin: () => Promise<void>;
|
openAllDebridLogin: () => Promise<void>;
|
||||||
importBestDebridCookies: () => Promise<number>;
|
importBestDebridCookies: () => Promise<number>;
|
||||||
|
|||||||
324
tests/debug-server.test.ts
Normal file
324
tests/debug-server.test.ts
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../src/main/windows-host-diagnostics", () => ({
|
||||||
|
getWindowsHostDiagnostics: () => ({
|
||||||
|
collectedAt: "2026-03-09T00:00:03.000Z",
|
||||||
|
supported: true,
|
||||||
|
platform: "win32",
|
||||||
|
crashControl: {
|
||||||
|
crashDumpEnabled: 3,
|
||||||
|
minidumpDir: "C:\\Windows\\Minidumps",
|
||||||
|
dumpFile: "C:\\Windows\\MEMORY.DMP",
|
||||||
|
overwrite: 1,
|
||||||
|
logEvent: 1,
|
||||||
|
autoReboot: 1
|
||||||
|
},
|
||||||
|
recentKernelPower: [
|
||||||
|
{
|
||||||
|
timeCreated: "2026-03-09T00:00:04.000Z",
|
||||||
|
id: 41,
|
||||||
|
providerName: "Microsoft-Windows-Kernel-Power",
|
||||||
|
levelDisplayName: "Critical",
|
||||||
|
message: "unexpected restart",
|
||||||
|
bugcheckCode: "0",
|
||||||
|
bugcheckCodeHex: "",
|
||||||
|
reportId: ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
recentWerKernel: [],
|
||||||
|
recentKernelDump: [],
|
||||||
|
recentAppCrashes: [],
|
||||||
|
recentMinidumps: [],
|
||||||
|
assessmentHints: ["watchdog hint"],
|
||||||
|
errors: []
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
|
||||||
|
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||||
|
import { configureLogger, getLogFilePath } from "../src/main/logger";
|
||||||
|
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||||
|
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||||
|
import type { DownloadManager } from "../src/main/download-manager";
|
||||||
|
import type { UiSnapshot } from "../src/shared/types";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function getFreePort(): Promise<number> {
|
||||||
|
const probe = http.createServer();
|
||||||
|
probe.listen(0, "127.0.0.1");
|
||||||
|
await once(probe, "listening");
|
||||||
|
const address = probe.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("port probe failed");
|
||||||
|
}
|
||||||
|
probe.close();
|
||||||
|
await once(probe, "close");
|
||||||
|
return address.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReady(url: string): Promise<void> {
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error(`debug server not ready: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSnapshot(baseDir: string): UiSnapshot {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
outputDir: path.join(baseDir, "downloads"),
|
||||||
|
extractDir: path.join(baseDir, "extract")
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
session: {
|
||||||
|
version: 1,
|
||||||
|
packageOrder: ["pkg-1"],
|
||||||
|
packages: {
|
||||||
|
"pkg-1": {
|
||||||
|
id: "pkg-1",
|
||||||
|
name: "server-package",
|
||||||
|
outputDir: path.join(baseDir, "downloads", "server-package"),
|
||||||
|
extractDir: path.join(baseDir, "extract", "server-package"),
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: ["item-1", "item-2"],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
priority: "normal",
|
||||||
|
postProcessLabel: "",
|
||||||
|
createdAt: Date.now() - 30_000,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
"item-1": {
|
||||||
|
id: "item-1",
|
||||||
|
packageId: "pkg-1",
|
||||||
|
url: "https://hoster.example/file-1",
|
||||||
|
provider: "realdebrid",
|
||||||
|
providerLabel: "Real-Debrid",
|
||||||
|
status: "downloading",
|
||||||
|
retries: 1,
|
||||||
|
speedBps: 8 * 1024 * 1024,
|
||||||
|
downloadedBytes: 64 * 1024 * 1024,
|
||||||
|
totalBytes: 256 * 1024 * 1024,
|
||||||
|
progressPercent: 25,
|
||||||
|
fileName: "episode.part1.rar",
|
||||||
|
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part1.rar"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Download läuft (Real-Debrid)",
|
||||||
|
createdAt: Date.now() - 30_000,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
},
|
||||||
|
"item-2": {
|
||||||
|
id: "item-2",
|
||||||
|
packageId: "pkg-1",
|
||||||
|
url: "https://hoster.example/file-2",
|
||||||
|
provider: "realdebrid",
|
||||||
|
providerLabel: "Real-Debrid",
|
||||||
|
status: "failed",
|
||||||
|
retries: 3,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: "episode.part2.rar",
|
||||||
|
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part2.rar"),
|
||||||
|
resumable: false,
|
||||||
|
attempts: 3,
|
||||||
|
lastError: "hoster unavailable",
|
||||||
|
fullStatus: "Fehler: hoster unavailable",
|
||||||
|
createdAt: Date.now() - 30_000,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runStartedAt: Date.now() - 30_000,
|
||||||
|
totalDownloadedBytes: 64 * 1024 * 1024,
|
||||||
|
summaryText: "",
|
||||||
|
reconnectUntil: 0,
|
||||||
|
reconnectReason: "",
|
||||||
|
paused: false,
|
||||||
|
running: true,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
},
|
||||||
|
summary: null,
|
||||||
|
stats: {
|
||||||
|
totalDownloaded: 64 * 1024 * 1024,
|
||||||
|
totalDownloadedAllTime: 128 * 1024 * 1024,
|
||||||
|
totalFilesSession: 0,
|
||||||
|
totalFilesAllTime: 0,
|
||||||
|
totalPackages: 1,
|
||||||
|
sessionStartedAt: Date.now() - 30_000
|
||||||
|
},
|
||||||
|
speedText: "8.0 MB/s",
|
||||||
|
etaText: "ETA: 00:25",
|
||||||
|
canStart: false,
|
||||||
|
canStop: true,
|
||||||
|
canPause: true,
|
||||||
|
clipboardActive: false,
|
||||||
|
reconnectSeconds: 0,
|
||||||
|
packageSpeedBps: {
|
||||||
|
"pkg-1": 8 * 1024 * 1024
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixture() {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-"));
|
||||||
|
tempDirs.push(baseDir);
|
||||||
|
const token = "debug-secret";
|
||||||
|
const port = await getFreePort();
|
||||||
|
const snapshot = buildSnapshot(baseDir);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
||||||
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8");
|
||||||
|
|
||||||
|
configureLogger(baseDir);
|
||||||
|
fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8");
|
||||||
|
|
||||||
|
initSessionLog(baseDir);
|
||||||
|
const sessionLogPath = getSessionLogPath();
|
||||||
|
if (!sessionLogPath) {
|
||||||
|
throw new Error("session log path missing");
|
||||||
|
}
|
||||||
|
fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8");
|
||||||
|
|
||||||
|
initPackageLogs(baseDir);
|
||||||
|
initItemLogs(baseDir);
|
||||||
|
const packageLogPath = ensurePackageLog({
|
||||||
|
packageId: "pkg-1",
|
||||||
|
name: "server-package",
|
||||||
|
outputDir: snapshot.session.packages["pkg-1"]!.outputDir,
|
||||||
|
extractDir: snapshot.session.packages["pkg-1"]!.extractDir
|
||||||
|
});
|
||||||
|
if (!packageLogPath) {
|
||||||
|
throw new Error("package log path missing");
|
||||||
|
}
|
||||||
|
fs.appendFileSync(packageLogPath, "2026-03-09T00:00:02.000Z [INFO] PACKAGE-LINE\n", "utf8");
|
||||||
|
const itemLogPath = ensureItemLog({
|
||||||
|
itemId: "item-2",
|
||||||
|
packageId: "pkg-1",
|
||||||
|
packageName: "server-package",
|
||||||
|
fileName: "episode.part2.rar",
|
||||||
|
targetPath: snapshot.session.items["item-2"]!.targetPath
|
||||||
|
});
|
||||||
|
if (!itemLogPath) {
|
||||||
|
throw new Error("item log path missing");
|
||||||
|
}
|
||||||
|
fs.appendFileSync(itemLogPath, "2026-03-09T00:00:03.000Z [ERROR] ITEM-LINE\n", "utf8");
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
getPackageLogPath: (packageId: string) => packageId === "pkg-1" ? packageLogPath : null,
|
||||||
|
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
|
||||||
|
} as unknown as DownloadManager;
|
||||||
|
|
||||||
|
startDebugServer(manager, baseDir);
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
await waitForReady(`${baseUrl}/health?token=${token}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
token
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stopDebugServer();
|
||||||
|
shutdownSessionLog();
|
||||||
|
shutdownPackageLogs();
|
||||||
|
shutdownItemLogs();
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const dir = tempDirs.pop();
|
||||||
|
if (!dir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("debug-server", () => {
|
||||||
|
it("serves diagnostics with main, session, and package log tails", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const payload = await response.json() as Record<string, any>;
|
||||||
|
|
||||||
|
expect(payload.meta?.appVersion).toBeTruthy();
|
||||||
|
expect(payload.meta?.debugServer?.host).toBe("0.0.0.0");
|
||||||
|
expect(payload.status?.running).toBe(true);
|
||||||
|
expect(payload.host?.platform).toBe("win32");
|
||||||
|
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
|
||||||
|
expect(payload.selectedPackage?.name).toBe("server-package");
|
||||||
|
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
|
||||||
|
expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
|
||||||
|
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves package details and package log by package query", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
|
||||||
|
const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`);
|
||||||
|
expect(packagesResponse.ok).toBe(true);
|
||||||
|
const packagesPayload = await packagesResponse.json() as Record<string, any>;
|
||||||
|
expect(packagesPayload.count).toBe(1);
|
||||||
|
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
|
||||||
|
|
||||||
|
const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`);
|
||||||
|
expect(logResponse.ok).toBe(true);
|
||||||
|
const logPayload = await logResponse.json() as Record<string, any>;
|
||||||
|
expect(logPayload.package?.name).toBe("server-package");
|
||||||
|
expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves item log by item query", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
|
||||||
|
const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const payload = await response.json() as Record<string, any>;
|
||||||
|
expect(payload.item?.id).toBe("item-2");
|
||||||
|
expect(payload.item?.fileName).toBe("episode.part2.rar");
|
||||||
|
expect((payload.lines || []).join("\n")).toContain("ITEM-LINE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves host diagnostics separately", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const payload = await response.json() as Record<string, any>;
|
||||||
|
expect(payload.platform).toBe("win32");
|
||||||
|
expect(payload.crashControl?.crashDumpEnabled).toBe(3);
|
||||||
|
expect(payload.assessmentHints?.[0]).toContain("watchdog");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated requests", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const response = await fetch(`${fixture.baseUrl}/status`);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/item-log.test.ts
Normal file
66
tests/item-log.test.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { ensureItemLog, getItemLogPath, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
shutdownItemLogs();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("item-log", () => {
|
||||||
|
it("creates a persistent item log file", () => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||||
|
tempDirs.push(baseDir);
|
||||||
|
|
||||||
|
initItemLogs(baseDir);
|
||||||
|
const logPath = ensureItemLog({
|
||||||
|
itemId: "item-1",
|
||||||
|
packageId: "pkg-1",
|
||||||
|
packageName: "Test Paket",
|
||||||
|
fileName: "episode.part2.rar",
|
||||||
|
targetPath: "C:\\downloads\\Test Paket\\episode.part2.rar"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logPath).not.toBeNull();
|
||||||
|
expect(fs.existsSync(logPath!)).toBe(true);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(logPath!, "utf8");
|
||||||
|
expect(content).toContain("Item-Log Start");
|
||||||
|
expect(content).toContain("episode.part2.rar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes detail events into the item log", async () => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||||
|
tempDirs.push(baseDir);
|
||||||
|
|
||||||
|
initItemLogs(baseDir);
|
||||||
|
ensureItemLog({
|
||||||
|
itemId: "item-2",
|
||||||
|
packageId: "pkg-2",
|
||||||
|
packageName: "Detail Paket",
|
||||||
|
fileName: "episode.part2.rar",
|
||||||
|
targetPath: "C:\\downloads\\Detail Paket\\episode.part2.rar"
|
||||||
|
});
|
||||||
|
|
||||||
|
logItemEvent("item-2", "ERROR", "Entpack-Fehler", {
|
||||||
|
archive: "episode.part2.rar",
|
||||||
|
code: "missing_parts",
|
||||||
|
detail: "Unexpected end of archive"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
|
||||||
|
const logPath = getItemLogPath("item-2");
|
||||||
|
expect(logPath).not.toBeNull();
|
||||||
|
const content = fs.readFileSync(logPath!, "utf8");
|
||||||
|
expect(content).toContain("Entpack-Fehler");
|
||||||
|
expect(content).toContain("archive=episode.part2.rar");
|
||||||
|
expect(content).toContain("code=missing_parts");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user