diff --git a/README.md b/README.md index a2ded6b..0f6b3d6 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ Runtime files are stored in Electron's `userData` directory, including: - `rd_history.json` - `rd_downloader.log` - `audit.log` +- `rename.log` - `debug_ai_manifest.json` - `trace.log` - `trace_config.json` @@ -214,7 +215,7 @@ Runtime files are stored in Electron's `userData` directory, including: - `package-logs/package_*.txt` - `item-logs/item_*.txt` -`audit.log` and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically. +`audit.log`, `rename.log`, and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically. ### Remote debug server @@ -253,6 +254,7 @@ Available endpoints after restart: - `GET /log?lines=100&grep=keyword` - `GET /logs/main?lines=100&grep=keyword` - `GET /logs/audit?lines=100&grep=keyword` +- `GET /logs/rename?lines=100&grep=keyword` - `GET /logs/trace?lines=100&grep=keyword` - `GET /logs/session?lines=100&grep=keyword` - `GET /logs/package?package=Release&lines=100&grep=keyword` @@ -277,6 +279,7 @@ Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200" +Invoke-RestMethod "http://SERVER:9868/logs/rename?token=YOUR_TOKEN&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200" Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120" Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200" @@ -285,7 +288,7 @@ Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip" ``` -This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely. +This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, rename/MKV move traces, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely. ## Troubleshooting diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 6d547ac..c144012 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -39,6 +39,7 @@ import { encryptBackup, decryptBackup } from "./backup-crypto"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getDebugSetupCheck } from "./debug-setup"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; +import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; @@ -82,6 +83,7 @@ export class AppController { initPackageLogs(this.storagePaths.baseDir); initItemLogs(this.storagePaths.baseDir); initAuditLog(this.storagePaths.baseDir); + initRenameLog(this.storagePaths.baseDir); initTraceLog(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); const session = loadSession(this.storagePaths); @@ -186,6 +188,10 @@ export class AppController { return getAuditLogPath(); } + public getRenameLogPath(): string | null { + return getRenameLogPath(); + } + public getTraceLogPath(): string | null { return getTraceLogPath(); } @@ -643,6 +649,7 @@ export class AppController { shutdownSessionLog(); shutdownPackageLogs(); shutdownItemLogs(); + shutdownRenameLog(); this.audit("INFO", "App beendet"); shutdownTraceLog(); shutdownAuditLog(); diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 3785bc8..dc32bc2 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -9,6 +9,7 @@ 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 { getRenameLogPath } from "./rename-log"; import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; @@ -38,6 +39,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." }, { method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." }, { method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." }, + { method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." }, { method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." }, { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, @@ -240,7 +242,7 @@ function buildAiManifest(baseDir: string): Record { "If remote access is needed, ask the user only for the server IP or DNS name.", "Call /meta first to confirm the server is reachable and to re-read the endpoint list.", "Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.", - "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.", + "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.", "If a full handoff is needed, download /support/bundle as a ZIP." ], auth: { @@ -257,6 +259,7 @@ function buildAiManifest(baseDir: string): Record { tokenFile: path.join(baseDir, "debug_token.txt"), mainLogFile: getLogFilePath(), auditLogFile: getAuditLogPath(), + renameLogFile: getRenameLogPath(), traceLogFile: getTraceLogPath(), traceConfigFile: getTraceConfigPath(), sessionLogFile: getSessionLogPath(), @@ -483,6 +486,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi logPaths: { main: getLogFilePath(), audit: getAuditLogPath(), + rename: getRenameLogPath(), session: getSessionLogPath(), trace: getTraceLogPath() }, @@ -522,6 +526,19 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/logs/rename") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getRenameLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + lines, + count: lines.length + }); + return; + } + if (pathname === "/logs/trace") { const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const grep = url.searchParams.get("grep") || ""; @@ -847,6 +864,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi path: getAuditLogPath(), lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep) }, + rename: { + path: getRenameLogPath(), + lines: filterLines(readLogTailFromFile(getRenameLogPath(), lineCount), grep) + }, trace: { path: getTraceLogPath(), config: getTraceConfig(), diff --git a/src/main/debug-setup.ts b/src/main/debug-setup.ts index ca16990..5d06a3f 100644 --- a/src/main/debug-setup.ts +++ b/src/main/debug-setup.ts @@ -279,6 +279,8 @@ function getSupportBundleEstimate( + Number(logSummary.mainBackup.exists) + Number(logSummary.audit.exists) + Number(logSummary.auditBackup.exists) + + Number(logSummary.rename.exists) + + Number(logSummary.renameBackup.exists) + Number(logSummary.session.exists) + Number(logSummary.trace.exists) + Number(logSummary.traceBackup.exists) @@ -317,6 +319,8 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")), audit: getFileSizeInfo(path.join(baseDir, "audit.log")), auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")), + rename: getFileSizeInfo(path.join(baseDir, "rename.log")), + renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")), session: getFileSizeInfo(sessionLogPath), trace: getFileSizeInfo(traceLogPath), traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")), @@ -330,6 +334,8 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { logSummary.mainBackup.bytes, logSummary.audit.bytes, logSummary.auditBackup.bytes, + logSummary.rename.bytes, + logSummary.renameBackup.bytes, logSummary.session.bytes, logSummary.trace.bytes, logSummary.traceBackup.bytes, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index e30b7b6..92370df 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -55,6 +55,7 @@ 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 { logRenameEvent as writeRenameLogEvent } from "./rename-log"; import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage"; import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils"; @@ -185,6 +186,28 @@ function inspectPackageItemDiskState(pkg: PackageEntry, item: DownloadItem): Pac } } +function stripArchiveSuffixForMatching(fileName: string): string { + const trimmed = path.basename(String(fileName || "").trim()); + if (!trimmed) { + return ""; + } + let next = trimmed.replace(/\.(?:part\d+\.rar|zip\.\d+|7z\.\d+|rar|r\d{2,3}|zip|7z|\d{3})$/i, ""); + next = next.replace(/\.part\d+$/i, "").replace(/\.vol\d+[+\d]*$/i, ""); + return next.toLowerCase(); +} + +function isPreferredArchiveEntryPointName(fileName: string): boolean { + const normalized = path.basename(String(fileName || "").trim()).toLowerCase(); + if (!normalized) { + return false; + } + return /\.part0*1\.rar$/.test(normalized) + || (/\.rar$/.test(normalized) && !/\.part\d+\.rar$/.test(normalized) && !/\.r\d{2,3}$/.test(normalized)) + || /\.zip\.001$/.test(normalized) + || /\.7z\.001$/.test(normalized) + || (/\.001$/.test(normalized) && !/\.(zip|7z)\.001$/.test(normalized)); +} + function getDownloadStallTimeoutMs(): number { const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) { @@ -1438,6 +1461,141 @@ export class DownloadManager extends EventEmitter { }); } + private logItemOnly( + item: DownloadItem, + level: "INFO" | "WARN" | "ERROR", + message: string, + fields?: Record + ): void { + const pkg = this.session.packages[item.packageId]; + this.ensureItemLogForItem(item); + writeItemLogEvent(item.id, level, message, { + packageId: item.packageId, + packageName: pkg?.name || "", + itemId: item.id, + fileName: item.fileName, + status: item.status, + targetPath: item.targetPath, + ...fields + }); + } + + private collectRenameMatchTokensForItem(pkg: PackageEntry, item: DownloadItem): string[] { + const tokens = new Set(); + const maybeAdd = (value: string | null | undefined): void => { + const normalized = String(value || "").trim().toLowerCase(); + if (!normalized || normalized.length < 4) { + return; + } + tokens.add(normalized); + }; + + maybeAdd(stripArchiveSuffixForMatching(item.fileName || "")); + maybeAdd(stripArchiveSuffixForMatching(item.targetPath ? path.basename(item.targetPath) : "")); + const diskPath = resolvePackageItemDiskPath(pkg, item); + if (diskPath) { + maybeAdd(stripArchiveSuffixForMatching(path.basename(diskPath))); + } + const episodeToken = extractEpisodeToken(item.fileName || path.basename(item.targetPath || "")); + if (episodeToken) { + maybeAdd(episodeToken); + } + return [...tokens].sort((a, b) => b.length - a.length); + } + + private inferItemForMediaLog( + pkg: PackageEntry, + ...candidates: Array + ): { item: DownloadItem | null; matchedBy: string | null } { + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + return { item: null, matchedBy: null }; + } + if (items.length === 1) { + return { item: items[0] || null, matchedBy: items[0] ? "single_item_package" : null }; + } + + const haystack = candidates + .map((value) => String(value || "").trim().toLowerCase()) + .filter(Boolean) + .join(" || "); + if (!haystack) { + return { item: null, matchedBy: null }; + } + + let bestItem: DownloadItem | null = null; + let bestScore = 0; + let bestMatchedBy: string | null = null; + let bestPreferredEntry = false; + let ambiguous = false; + + for (const item of items) { + const fileName = item.fileName || path.basename(item.targetPath || ""); + const preferredEntry = isPreferredArchiveEntryPointName(fileName); + let score = preferredEntry ? 5 : 0; + let matchedBy: string | null = preferredEntry ? "entry_point" : null; + + const episodeToken = extractEpisodeToken(fileName); + if (episodeToken && haystack.includes(episodeToken.toLowerCase())) { + score = 110 + (preferredEntry ? 5 : 0); + matchedBy = "episode_token"; + } else { + for (const token of this.collectRenameMatchTokensForItem(pkg, item)) { + if (haystack.includes(token)) { + score = Math.max(score, Math.min(100, 40 + token.length) + (preferredEntry ? 5 : 0)); + matchedBy = token === episodeToken?.toLowerCase() ? "episode_token" : `token:${token}`; + break; + } + } + } + + if (score > bestScore || (score === bestScore && score > 0 && preferredEntry && !bestPreferredEntry)) { + bestItem = item; + bestScore = score; + bestMatchedBy = matchedBy; + bestPreferredEntry = preferredEntry; + ambiguous = false; + continue; + } + if (score > 0 && score === bestScore && preferredEntry === bestPreferredEntry) { + ambiguous = true; + } + } + + if (ambiguous || !bestItem || bestScore <= 0) { + return { item: null, matchedBy: null }; + } + return { item: bestItem, matchedBy: bestMatchedBy }; + } + + private logRenameProcess( + pkg: PackageEntry, + level: "INFO" | "WARN" | "ERROR", + stage: "auto-rename" | "mkv-move", + message: string, + fields?: Record, + item?: DownloadItem | null, + matchedBy?: string | null + ): void { + writeRenameLogEvent(level, message, { + stage, + packageId: pkg.id, + packageName: pkg.name, + ...(item ? { itemId: item.id, fileName: item.fileName } : {}), + ...(matchedBy ? { matchedBy } : {}), + ...fields + }); + if (item) { + this.logItemOnly(item, level, message, { + stage, + ...(matchedBy ? { matchedBy } : {}), + ...fields + }); + } + } + public setSettings(next: AppSettings): void { const previous = this.settings; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); @@ -2793,6 +2951,10 @@ export class DownloadManager extends EventEmitter { extractDir, videoFiles: videoFiles.length }); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename Scan gestartet", { + extractDir, + videoFiles: videoFiles.length + }); } let renamed = 0; @@ -2840,6 +3002,12 @@ export class DownloadManager extends EventEmitter { const targetBaseName = buildAutoRenameBaseNameFromFoldersWithOptions(folderCandidates, sourceBaseName, { forceEpisodeForSeasonFolder: true }); + const resolveRenameItem = (...extra: Array): { item: DownloadItem | null; matchedBy: string | null } => { + if (!pkg) { + return { item: null, matchedBy: null }; + } + return this.inferItemForMediaLog(pkg, sourcePath, sourceName, folderCandidates.join(" "), targetBaseName || "", ...extra); + }; if (!targetBaseName) { if (pkg) { this.logPackageForPackage(pkg, "WARN", "Auto-Rename übersprungen: kein Zielname", { @@ -2847,6 +3015,13 @@ export class DownloadManager extends EventEmitter { sourceBaseName, folders: folderCandidates.join(", ") }); + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename übersprungen: kein Zielname", { + sourcePath, + sourceName, + sourceBaseName, + folders: folderCandidates.join(", ") + }, resolved.item, resolved.matchedBy); } logger.info(`Auto-Rename: kein Zielname für ${sourceName} (folders=${folderCandidates.join(", ")})`); continue; @@ -2859,6 +3034,16 @@ export class DownloadManager extends EventEmitter { targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, fallbackBaseName, sourceExt); if (targetPath) { logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(targetPath)}`); + if (pkg) { + const resolved = resolveRenameItem(targetPath, fallbackBaseName); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename Fallback wegen Pfadlänge gewählt", { + sourcePath, + sourceName, + targetPath, + targetBaseName, + fallbackBaseName + }, resolved.item, resolved.matchedBy); + } } } if (!targetPath) { @@ -2867,6 +3052,16 @@ export class DownloadManager extends EventEmitter { targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, veryShortFallback, sourceExt); if (targetPath) { logger.warn(`Auto-Rename Kurz-Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(targetPath)}`); + if (pkg) { + const resolved = resolveRenameItem(targetPath, veryShortFallback); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename Kurz-Fallback wegen Pfadlänge gewählt", { + sourcePath, + sourceName, + targetPath, + targetBaseName, + fallbackBaseName: veryShortFallback + }, resolved.item, resolved.matchedBy); + } } } } @@ -2878,11 +3073,27 @@ export class DownloadManager extends EventEmitter { sourceBaseName, targetBaseName }); + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename übersprungen: Zielpfad ungültig", { + sourcePath, + sourceName, + sourceBaseName, + targetBaseName + }, resolved.item, resolved.matchedBy); } logger.warn(`Auto-Rename übersprungen (Zielpfad zu lang/ungültig): ${sourcePath}`); continue; } if (pathKey(targetPath) === pathKey(sourcePath)) { + if (pkg) { + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename übersprungen: Name bereits passend", { + sourcePath, + sourceName, + targetPath, + targetBaseName + }, resolved.item, resolved.matchedBy); + } continue; } if (await this.existsAsync(targetPath)) { @@ -2891,6 +3102,13 @@ export class DownloadManager extends EventEmitter { sourceName, targetPath }); + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename übersprungen: Ziel existiert", { + sourcePath, + sourceName, + targetPath, + targetBaseName + }, resolved.item, resolved.matchedBy); } logger.warn(`Auto-Rename übersprungen (Ziel existiert): ${targetPath}`); continue; @@ -2904,6 +3122,14 @@ export class DownloadManager extends EventEmitter { targetPath, sourceName }); + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename durchgeführt", { + sourcePath, + targetPath, + sourceName, + targetBaseName, + folders: folderCandidates.join(", ") + }, resolved.item, resolved.matchedBy); } logger.info(`Auto-Rename: ${sourceName} -> ${path.basename(targetPath)}`); renamed += 1; @@ -2926,6 +3152,16 @@ export class DownloadManager extends EventEmitter { await this.renamePathWithExdevFallback(sourcePath, fallbackPath); logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(fallbackPath)}`); renamed += 1; + if (pkg) { + const resolved = resolveRenameItem(fallbackPath, fallbackBaseName); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename Fallback durchgeführt", { + sourcePath, + sourceName, + targetPath: fallbackPath, + targetBaseName, + fallbackBaseName + }, resolved.item, resolved.matchedBy); + } fallbackRenamed = true; break; } catch { @@ -2942,6 +3178,15 @@ export class DownloadManager extends EventEmitter { sourceName, error: compactErrorText(error) }); + const resolved = resolveRenameItem(targetPath); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename fehlgeschlagen", { + sourcePath, + sourceName, + targetPath, + targetBaseName, + folders: folderCandidates.join(", "), + error: compactErrorText(error) + }, resolved.item, resolved.matchedBy); } } } @@ -2952,6 +3197,10 @@ export class DownloadManager extends EventEmitter { this.logPackageForPackage(pkg, "INFO", "Auto-Rename abgeschlossen", { renamed }); + this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename abgeschlossen", { + extractDir, + renamed + }); } } return renamed; @@ -3152,6 +3401,12 @@ export class DownloadManager extends EventEmitter { return; } + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Sammelordner Scan gestartet", { + sourceDir, + targetDir, + mkvFiles: mkvFiles.length + }); + const reservedTargets = new Set(); let moved = 0; let skipped = 0; @@ -3174,6 +3429,12 @@ export class DownloadManager extends EventEmitter { } if (sourceSize === 0) { logger.warn(`MKV-Sammelordner: überspringe 0-Byte-Datei ${path.basename(sourcePath)}`); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetDir); + this.logRenameProcess(pkg, "WARN", "mkv-move", "MKV übersprungen: 0-Byte-Datei", { + sourcePath, + targetDir, + sourceSize + }, resolved.item, resolved.matchedBy); skipped += 1; continue; } @@ -3184,6 +3445,12 @@ export class DownloadManager extends EventEmitter { const existingStat = await fs.promises.stat(idealTargetPath); if (existingStat.size === sourceSize) { logger.info(`MKV-Sammelordner: Duplikat übersprungen (gleiche Größe ${humanSize(sourceSize)}): ${path.basename(sourcePath)}`); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), idealTargetPath); + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Duplikat übersprungen", { + sourcePath, + targetPath: idealTargetPath, + sourceSize + }, resolved.item, resolved.matchedBy); // Remove the duplicate source file to avoid future re-processing try { await fs.promises.unlink(sourcePath); } catch { /* ignore */ } skipped += 1; @@ -3207,6 +3474,12 @@ export class DownloadManager extends EventEmitter { targetPath, sourceSize }); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetPath); + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV verschoben", { + sourcePath, + targetPath, + sourceSize + }, resolved.item, resolved.matchedBy); } catch (error) { failed += 1; logger.warn(`MKV verschieben fehlgeschlagen: ${sourcePath} -> ${targetPath} (${compactErrorText(error)})`); @@ -3215,6 +3488,13 @@ export class DownloadManager extends EventEmitter { targetPath, error: compactErrorText(error) }); + const resolved = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetPath); + this.logRenameProcess(pkg, "WARN", "mkv-move", "MKV verschieben fehlgeschlagen", { + sourcePath, + targetPath, + sourceSize, + error: compactErrorText(error) + }, resolved.item, resolved.matchedBy); } } @@ -3230,6 +3510,13 @@ export class DownloadManager extends EventEmitter { } logger.info(`MKV-Sammelordner: pkg=${pkg.name}, packageId=${packageId}, moved=${moved}, skipped=${skipped}, failed=${failed}, target=${targetDir}`); + this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Sammelordner abgeschlossen", { + sourceDir, + targetDir, + moved, + skipped, + failed + }); } public cancelPackage(packageId: string): void { diff --git a/src/main/main.ts b/src/main/main.ts index 3c032ba..3b13040 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -550,6 +550,13 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => { + const logPath = controller.getRenameLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { const logPath = controller.getSessionLogPath(); if (logPath) { diff --git a/src/main/rename-log.ts b/src/main/rename-log.ts new file mode 100644 index 0000000..72f5102 --- /dev/null +++ b/src/main/rename-log.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import path from "node:path"; + +type RenameLogLevel = "INFO" | "WARN" | "ERROR"; + +const RENAME_LOG_MAX_FILE_BYTES = Number(process.env.RD_RENAME_LOG_MAX_BYTES || 10 * 1024 * 1024); +const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30); + +let renameLogPath: string | null = null; + +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 rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < RENAME_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + // ignore + } + fs.renameSync(filePath, backup); + } catch { + // ignore + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + // ignore + } +} + +export function initRenameLog(baseDir: string): void { + renameLogPath = path.join(baseDir, "rename.log"); + try { + fs.mkdirSync(path.dirname(renameLogPath), { recursive: true }); + cleanupOldBackup(renameLogPath); + if (!fs.existsSync(renameLogPath)) { + fs.writeFileSync(renameLogPath, "", "utf8"); + } + rotateIfNeeded(renameLogPath); + if (!fs.existsSync(renameLogPath)) { + fs.writeFileSync(renameLogPath, "", "utf8"); + } + fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${new Date().toISOString()} ===\n`, "utf8"); + } catch { + renameLogPath = null; + } +} + +export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record): void { + if (!renameLogPath) { + return; + } + try { + rotateIfNeeded(renameLogPath); + if (!fs.existsSync(renameLogPath)) { + fs.writeFileSync(renameLogPath, "", "utf8"); + } + fs.appendFileSync( + renameLogPath, + `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`, + "utf8" + ); + } catch { + // ignore write errors + } +} + +export function getRenameLogPath(): string | null { + if (!renameLogPath) { + return null; + } + return fs.existsSync(renameLogPath) ? renameLogPath : null; +} + +export function shutdownRenameLog(): void { + if (!renameLogPath) { + return; + } + try { + fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${new Date().toISOString()} ===\n`, "utf8"); + } catch { + // ignore + } + renameLogPath = null; +} diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index ab3f4d6..01a4671 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -6,6 +6,7 @@ import { getAuditLogPath } from "./audit-log"; import { getDebugSetupCheck } from "./debug-setup"; import { getLogFilePath } from "./logger"; import { getPackageLogPath } from "./package-log"; +import { getRenameLogPath } from "./rename-log"; import { getSessionLogPath } from "./session-log"; import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; @@ -120,6 +121,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); + addFileIfExists(zip, getRenameLogPath(), "logs/rename.log"); + addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old"); addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 6859e62..85e601f 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -61,6 +61,7 @@ const api: ElectronApi = { exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), + openRenameLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG), openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), openTraceLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG), openPackageLog: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f5d5150..e0dd4e1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -169,6 +169,7 @@ function buildDebugSetupDetails(setup: DebugSetupCheckResult): string { formatFileLine("Gesamt", setup.logSummary.totalBytes), formatFileLine("Hauptlog", setup.logSummary.main.bytes + setup.logSummary.mainBackup.bytes), formatFileLine("Audit", setup.logSummary.audit.bytes + setup.logSummary.auditBackup.bytes), + formatFileLine("Rename", setup.logSummary.rename.bytes + setup.logSummary.renameBackup.bytes), formatFileLine("Trace", setup.logSummary.trace.bytes + setup.logSummary.traceBackup.bytes), `${formatFileLine("Session-Logs", setup.logSummary.session.bytes + setup.logSummary.sessionLogs.bytes)} | Dateien: ${setup.logSummary.sessionLogs.fileCount}`, `${formatFileLine("Paket-Logs", setup.logSummary.packageLogs.bytes)} | Dateien: ${setup.logSummary.packageLogs.fileCount}`, @@ -3917,6 +3918,9 @@ export function App(): ReactElement { + diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a7d955f..78b3654 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -41,6 +41,7 @@ export const IPC_CHANNELS = { EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle", OPEN_LOG: "app:open-log", OPEN_AUDIT_LOG: "app:open-audit-log", + OPEN_RENAME_LOG: "app:open-rename-log", OPEN_SESSION_LOG: "app:open-session-log", OPEN_TRACE_LOG: "app:open-trace-log", OPEN_PACKAGE_LOG: "app:open-package-log", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 9605931..a35a917 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -58,6 +58,7 @@ export interface ElectronApi { exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; openLog: () => Promise; openAuditLog: () => Promise; + openRenameLog: () => Promise; openSessionLog: () => Promise; openTraceLog: () => Promise; openPackageLog: (packageId: string) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 27017d6..c3d87e6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -399,6 +399,8 @@ export interface DebugSetupCheckResult { mainBackup: SupportFileSizeInfo; audit: SupportFileSizeInfo; auditBackup: SupportFileSizeInfo; + rename: SupportFileSizeInfo; + renameBackup: SupportFileSizeInfo; session: SupportFileSizeInfo; trace: SupportFileSizeInfo; traceBackup: SupportFileSizeInfo; diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 2029a59..17486b3 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -46,6 +46,7 @@ import { startDebugServer, stopDebugServer } from "../src/main/debug-server"; import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { configureLogger, getLogFilePath, logger } from "../src/main/logger"; import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; +import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log"; import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage"; import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log"; @@ -235,6 +236,9 @@ async function createFixture() { } logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" }); + initRenameLog(baseDir); + logRenameEvent("INFO", "RENAME-LINE", { stage: "auto-rename", sourcePath: "C:\\extract\\old.mkv" }); + initTraceLog(baseDir); setTraceEnabled(true, "test-fixture"); logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" }); @@ -294,6 +298,7 @@ afterEach(() => { shutdownSessionLog(); shutdownPackageLogs(); shutdownItemLogs(); + shutdownRenameLog(); shutdownTraceLog(); shutdownAuditLog(); while (tempDirs.length > 0) { @@ -324,6 +329,7 @@ describe("debug-server", () => { expect(payload.selectedPackage?.name).toBe("server-package"); expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE"); expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE"); + expect((payload.logs?.rename?.lines || []).join("\n")).toContain("RENAME-LINE"); expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT"); expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE"); expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE"); @@ -353,6 +359,7 @@ describe("debug-server", () => { expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath); expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath()); expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath()); + expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath()); expect(metaPayload.supportChecks?.setup).toBe("/debug/setup"); expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check"); }); @@ -376,6 +383,7 @@ describe("debug-server", () => { expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0); expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0); expect(payload.logSummary?.totalBytes).toBeGreaterThan(0); + expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0); expect(payload.logSummary?.packageLogs?.fileCount).toBe(1); expect(payload.logSummary?.itemLogs?.fileCount).toBe(1); expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0); @@ -437,6 +445,11 @@ describe("debug-server", () => { const auditPayload = await auditResponse.json() as Record; expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE"); + const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`); + expect(renameResponse.ok).toBe(true); + const renamePayload = await renameResponse.json() as Record; + expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE"); + const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`); expect(traceResponse.ok).toBe(true); const tracePayload = await traceResponse.json() as Record; @@ -492,6 +505,7 @@ describe("debug-server", () => { expect(entries).toContain("overview/self-check.json"); expect(entries).toContain("overview/trace-config.json"); expect(entries).toContain("logs/audit.log"); + expect(entries).toContain("logs/rename.log"); expect(entries).toContain("logs/trace.log"); expect(entries).toContain("runtime/debug_ai_manifest.json"); expect(entries).not.toContain("runtime/debug_token.txt"); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 25a8075..d7def29 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -9,8 +9,10 @@ import { DownloadManager, extractArchiveNameFromExtractorLogMessage, getAuthorit import { defaultSettings } from "../src/main/constants"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; +import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { createStoragePaths, emptySession } from "../src/main/storage"; import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid"; +import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; const tempDirs: string[] = []; const originalFetch = globalThis.fetch; @@ -56,6 +58,8 @@ async function removeDirWithRetries(dir: string): Promise { afterEach(async () => { globalThis.fetch = originalFetch; resetDebridLinkRuntimeStateForTests(); + shutdownItemLogs(); + shutdownRenameLog(); for (const dir of tempDirs.splice(0)) { await removeDirWithRetries(dir); } @@ -6705,6 +6709,50 @@ describe("download manager", () => { expect(fs.existsSync(originalExtractedPath)).toBe(false); }); + it("writes auto-rename details into rename and item logs", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Asbest.S02.GERMAN.720p.WEB.AVC-4SF"; + const sourceFileName = "4sf-asbest.web.7p-s02e01.mkv"; + const expectedFileName = "Asbest.S02E01.GERMAN.720p.WEB.AVC-4SF.mkv"; + const { session, itemId, extractDir } = createCompletedArchiveSession(root, packageName, sourceFileName); + const stateDir = path.join(root, "state"); + initItemLogs(stateDir); + initRenameLog(stateDir); + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(stateDir) + ); + + const expectedPath = path.join(extractDir, expectedFileName); + await waitFor(() => fs.existsSync(expectedPath), 12000); + await new Promise((resolve) => setTimeout(resolve, 350)); + + const renameLogPath = getRenameLogPath(); + expect(renameLogPath).not.toBeNull(); + const renameContent = fs.readFileSync(renameLogPath!, "utf8"); + expect(renameContent).toContain("Auto-Rename durchgeführt"); + expect(renameContent).toContain(`targetPath=${expectedPath}`); + + const itemLogPath = getItemLogPath(itemId); + expect(itemLogPath).not.toBeNull(); + const itemContent = fs.readFileSync(itemLogPath!, "utf8"); + expect(itemContent).toContain("Auto-Rename durchgeführt"); + expect(itemContent).toContain("stage=auto-rename"); + }, 20000); + it("adds REPACK marker from rp token and supports 4SJ folders", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/rename-log.test.ts b/tests/rename-log.test.ts new file mode 100644 index 0000000..ba49823 --- /dev/null +++ b/tests/rename-log.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownRenameLog(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("rename-log", () => { + it("writes rename events to the rename log", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-")); + tempDirs.push(baseDir); + + initRenameLog(baseDir); + logRenameEvent("INFO", "Auto-Rename durchgeführt", { + packageName: "Test Paket", + sourcePath: "C:\\extract\\old.mkv", + targetPath: "C:\\extract\\new.mkv" + }); + + const logPath = getRenameLogPath(); + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Rename-Log Start"); + expect(content).toContain("Auto-Rename durchgeführt"); + expect(content).toContain("sourcePath=C:\\extract\\old.mkv"); + }); + + it("rotates oversized rename logs on startup", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-rotate-")); + tempDirs.push(baseDir); + + const oversizedPath = path.join(baseDir, "rename.log"); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8"); + + initRenameLog(baseDir); + + expect(fs.existsSync(oversizedPath)).toBe(true); + expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true); + const content = fs.readFileSync(oversizedPath, "utf8"); + expect(content).toContain("Rename-Log Start"); + }); +});