Renaming-Logging: lueckenloses Desktop-Protokoll pro Sitzung + Post-Rename-Verifikation
User-Goal: bei kuenftigen Renaming-Problemen eine vollstaendige, sofort auffindbare Uebersicht —
JEDER Umbenenn-/Verschiebevorgang protokolliert UND danach verifiziert (liegt die Datei wirklich
unter dem Zielnamen? Quelle weg? richtige Schreibweise?).
- NEU desktop-rename-log.ts: pro Sitzung <Desktop>/Downloader-Log/rename-session_<ts>.txt; Ordner
selbstheilend (mkdir recursive vor jedem Write -> auch nach Loeschung zur Laufzeit sofort wieder
da). Synchroner Append, Schreibfehler verschluckt (bricht nie einen Download).
- verifyRename (sync) + verifyRenameAsync (Hot-Path): prueft Ziel-Existenz, echten On-Disk-Namen
(case-genau via readdir), Quell-Abwesenheit; Level INFO/WARN/ERROR. Nutzt denselben \?\-Long-
Path-Prefix wie der echte Rename (sonst falsche Urteile auf langen Scene-Pfaden).
- download-manager: renamePathWithExdevFallback = verifizierter Wrapper um die unveraenderte
Raw-Logik (deckt alle Media-Renames ab) + 3 Sync-Sites (startup-Dedup, Deobfuskation, Suffix-Fix)
via logVerifiedRenameSync; logRenameProcess spiegelt ins Desktop-Log.
- app-controller init/shutdown (getPath("desktop") gegen Startup-Crash abgesichert); support-bundle
packt das Log mit ein.
Adversarialer Review-Workflow (4 Linsen) fand + behoben: Long-Path-Verify-Bug (falsches OK
maskiert halb-fertigen Move), readdir-Fehler-False-OK, sync-I/O im Hot-Path, getPath-Guard,
Test-Temp-Cleanup. tsc 9 (Baseline), 663 Tests (+7 neue), Build gruen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da72c11772
commit
251c41ca6c
@ -45,6 +45,7 @@ import { runStartupHealthCheck } from "./startup-health-check";
|
|||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||||||
|
import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||||
@ -91,6 +92,19 @@ export class AppController {
|
|||||||
initAuditLog(this.storagePaths.baseDir);
|
initAuditLog(this.storagePaths.baseDir);
|
||||||
initAccountRotationLog(this.storagePaths.baseDir);
|
initAccountRotationLog(this.storagePaths.baseDir);
|
||||||
initRenameLog(this.storagePaths.baseDir);
|
initRenameLog(this.storagePaths.baseDir);
|
||||||
|
// Session-eigenes Rename-Protokoll auf dem Desktop (eigener Ordner Downloader-Log,
|
||||||
|
// selbstheilend) — luekenlose Uebersicht + Post-Rename-Verifikation fuer kuenftige
|
||||||
|
// Renaming-Diagnose, getrennt von den userData-Logs. app.getPath("desktop") wird vom
|
||||||
|
// Aufrufer ausgewertet (ausserhalb der modul-internen try/catch) — der "desktop"-
|
||||||
|
// Known-Folder kann in Headless-/Service-Account-Setups scheitern, daher hier gegen
|
||||||
|
// einen Startup-Crash absichern (initDesktopRenameLog behandelt null als no-op).
|
||||||
|
let desktopDir: string | null = null;
|
||||||
|
try {
|
||||||
|
desktopDir = app.getPath("desktop");
|
||||||
|
} catch {
|
||||||
|
desktopDir = null;
|
||||||
|
}
|
||||||
|
initDesktopRenameLog(desktopDir);
|
||||||
initTraceLog(this.storagePaths.baseDir);
|
initTraceLog(this.storagePaths.baseDir);
|
||||||
this.settings = loadSettings(this.storagePaths);
|
this.settings = loadSettings(this.storagePaths);
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||||
@ -237,6 +251,10 @@ export class AppController {
|
|||||||
return getRenameLogPath();
|
return getRenameLogPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDesktopRenameLogPath(): string | null {
|
||||||
|
return getDesktopRenameLogPath();
|
||||||
|
}
|
||||||
|
|
||||||
public getTraceLogPath(): string | null {
|
public getTraceLogPath(): string | null {
|
||||||
return getTraceLogPath();
|
return getTraceLogPath();
|
||||||
}
|
}
|
||||||
@ -756,6 +774,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
shutdownPackageLogs();
|
shutdownPackageLogs();
|
||||||
shutdownItemLogs();
|
shutdownItemLogs();
|
||||||
shutdownRenameLog();
|
shutdownRenameLog();
|
||||||
|
shutdownDesktopRenameLog();
|
||||||
this.audit("INFO", "App beendet");
|
this.audit("INFO", "App beendet");
|
||||||
shutdownTraceLog();
|
shutdownTraceLog();
|
||||||
shutdownAccountRotationLog();
|
shutdownAccountRotationLog();
|
||||||
|
|||||||
318
src/main/desktop-rename-log.ts
Normal file
318
src/main/desktop-rename-log.ts
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { logTimestamp } from "./log-timestamp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-eigenes Rename-Protokoll auf dem DESKTOP des Nutzers.
|
||||||
|
*
|
||||||
|
* Ziel (User-Anforderung): bei zukuenftigen Renaming-Problemen eine luekenlose,
|
||||||
|
* sofort auffindbare Uebersicht haben — JEDER Umbenenn-/Verschiebevorgang wird
|
||||||
|
* protokolliert UND danach verifiziert (liegt die Datei wirklich unter dem
|
||||||
|
* Zielnamen auf der Platte? ist die Quelle weg?). Nur weil fs.rename "ok" meldet,
|
||||||
|
* heisst das nicht, dass das Ergebnis stimmt (Gross-/Kleinschreibung, Unicode-
|
||||||
|
* Normalisierung, halb-fertiger EXDEV-Copy ohne geloeschte Quelle, ...).
|
||||||
|
*
|
||||||
|
* - Pro Programm-Sitzung eine eigene Datei: <Desktop>/Downloader-Log/rename-session_<ts>.txt
|
||||||
|
* - Der Ordner wird beim Start angelegt UND vor JEDEM Schreibvorgang selbstheilend
|
||||||
|
* neu angelegt (mkdir recursive) — wird er zur Laufzeit geloescht, ist er beim
|
||||||
|
* naechsten Rename sofort wieder da, inkl. neu geschriebenem Session-Header.
|
||||||
|
* - Synchroner Append (wie rename-log.ts), kein gepufferter Flush: Renames sind
|
||||||
|
* selten genug, und so gibt es kein "geloescht-waehrend-Flush"-Zeitfenster.
|
||||||
|
* - Schlaegt das Logging fehl, wird der Fehler verschluckt — Logging darf einen
|
||||||
|
* Download niemals abbrechen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
|
const FOLDER_NAME = "Downloader-Log";
|
||||||
|
|
||||||
|
let logDir: string | null = null;
|
||||||
|
let logFilePath: string | null = null;
|
||||||
|
let sessionHeader = "";
|
||||||
|
|
||||||
|
/** Lokaler Zeitstempel fuer den DATEINAMEN (keine Doppelpunkte — unter Windows
|
||||||
|
* in Dateinamen verboten): YYYY-MM-DD_HH-MM-SS in lokaler Zeit. */
|
||||||
|
function fileTimestamp(date: Date = new Date()): string {
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
||||||
|
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(" | ")}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt sicher, dass Ordner UND Session-Datei existieren (selbstheilend, auch
|
||||||
|
* wenn beides zur Laufzeit geloescht wurde). Gibt false zurueck, wenn das
|
||||||
|
* Logging nicht initialisiert ist oder das Anlegen scheitert. */
|
||||||
|
function ensureWritable(): boolean {
|
||||||
|
if (!logDir || !logFilePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
if (!fs.existsSync(logFilePath)) {
|
||||||
|
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialisiert das Desktop-Rename-Log fuer diese Sitzung. `desktopDir` ist der
|
||||||
|
* Desktop-Pfad (app.getPath("desktop")). Faellt still auf no-op zurueck, wenn der
|
||||||
|
* Pfad fehlt oder nicht beschreibbar ist. */
|
||||||
|
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
||||||
|
try {
|
||||||
|
const base = String(desktopDir || "").trim();
|
||||||
|
if (!base) {
|
||||||
|
logDir = null;
|
||||||
|
logFilePath = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logDir = path.join(base, FOLDER_NAME);
|
||||||
|
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
|
||||||
|
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
|
||||||
|
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n"
|
||||||
|
+ "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n"
|
||||||
|
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
||||||
|
} catch {
|
||||||
|
logDir = null;
|
||||||
|
logFilePath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt eine Zeile ins Desktop-Rename-Log. Tut nichts, wenn nicht
|
||||||
|
* initialisiert; verschluckt jeden Schreibfehler (darf nie einen Download
|
||||||
|
* abbrechen). */
|
||||||
|
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
||||||
|
if (!ensureWritable() || !logFilePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
||||||
|
} catch {
|
||||||
|
// Logging darf einen Download niemals abbrechen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopRenameLogPath(): string | null {
|
||||||
|
if (!logFilePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.existsSync(logFilePath) ? logFilePath : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shutdownDesktopRenameLog(): void {
|
||||||
|
if (ensureWritable() && logFilePath) {
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logDir = null;
|
||||||
|
logFilePath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameVerification {
|
||||||
|
/** Gesamtergebnis: Datei liegt unter dem EXAKT erwarteten Namen vor und (sofern kein
|
||||||
|
* In-Place-Rename) die Quelle ist verschwunden. */
|
||||||
|
ok: boolean;
|
||||||
|
/** Empfohlenes Log-Level: ERROR (Rename nicht vollzogen / falscher Name),
|
||||||
|
* WARN (vollzogen, aber Schreibweise nicht pruefbar), INFO (alles ok). */
|
||||||
|
level: "INFO" | "WARN" | "ERROR";
|
||||||
|
/** Zieldatei (egal welche Schreibweise) auf der Platte vorhanden? */
|
||||||
|
targetExists: boolean;
|
||||||
|
/** Tatsaechlicher Name auf der Platte (Gross-/Kleinschreibung wie wirklich
|
||||||
|
* gespeichert), oder null wenn nicht gefunden / Verzeichnis nicht lesbar. */
|
||||||
|
onDiskName: string | null;
|
||||||
|
/** onDiskName === erwarteter Zielname (exakt, case-sensitive)? */
|
||||||
|
nameMatches: boolean;
|
||||||
|
/** Quelldatei verschwunden (Rename wirklich vollzogen, kein halb-fertiger Copy)? */
|
||||||
|
sourceGone: boolean;
|
||||||
|
/** Groesse der Zieldatei in Bytes, oder null. */
|
||||||
|
targetSize: number | null;
|
||||||
|
/** Menschenlesbarer Grund, wenn nicht sauber INFO. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repliziert download-manager.toWindowsLongPathIfNeeded (ein Import waere zirkulaer:
|
||||||
|
* download-manager -> desktop-rename-log). Node fs-Aufrufe scheitern unter Windows fuer
|
||||||
|
* absolute Pfade >=248 Zeichen, sofern nicht mit \\?\ / \\?\UNC\ praefixiert — und genau
|
||||||
|
* solche langen Scene-Release-Pfade benennt diese App um. OHNE dieses Prefix wuerden
|
||||||
|
* statSync/readdirSync in der Verifikation auf langen Pfaden faelschlich scheitern
|
||||||
|
* (falsches "Ziel nicht gefunden" UND falsches "Quelle weg" -> falsches OK, das einen
|
||||||
|
* halb-fertigen Verschiebevorgang maskiert). */
|
||||||
|
function toLongPath(filePath: string): string {
|
||||||
|
const absolute = path.resolve(String(filePath || ""));
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
if (!absolute || absolute.startsWith("\\\\?\\")) {
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
if (absolute.length < 248) {
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
if (absolute.startsWith("\\\\")) {
|
||||||
|
return `\\\\?\\UNC\\${absolute.slice(2)}`;
|
||||||
|
}
|
||||||
|
return `\\\\?\\${absolute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Echter On-Disk-Name (korrekte Schreibweise) fuer `requested` aus den
|
||||||
|
* Verzeichnis-Eintraegen, oder null wenn das Verzeichnis nicht lesbar war
|
||||||
|
* (entries===null) bzw. nichts passt. */
|
||||||
|
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
||||||
|
if (entries === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const requestedLower = requested.toLowerCase();
|
||||||
|
return entries.find((entry) => entry === requested)
|
||||||
|
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|
||||||
|
|| requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut das Verifikations-Ergebnis aus den (sync ODER async) erhobenen Roh-Fakten.
|
||||||
|
* `dirEntries`=null bedeutet "Zielverzeichnis war nicht lesbar". */
|
||||||
|
function buildVerification(
|
||||||
|
sourcePath: string,
|
||||||
|
targetPath: string,
|
||||||
|
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
|
||||||
|
): RenameVerification {
|
||||||
|
const requested = path.basename(targetPath);
|
||||||
|
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
||||||
|
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
||||||
|
|
||||||
|
// In-Place-Rename (reine Gross-/Kleinschreibungs-Korrektur auf case-insensitivem FS):
|
||||||
|
// Quelle == Ziel -> "Quelle weg" gilt nicht.
|
||||||
|
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
||||||
|
const sourceGone = samePath ? true : !facts.sourceExists;
|
||||||
|
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
||||||
|
|
||||||
|
const problems: string[] = [];
|
||||||
|
let level: "INFO" | "WARN" | "ERROR" = "INFO";
|
||||||
|
if (!facts.targetExists) {
|
||||||
|
problems.push("Zieldatei nach Rename NICHT gefunden");
|
||||||
|
level = "ERROR";
|
||||||
|
} else if (!dirReadFailed && !nameMatches) {
|
||||||
|
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
|
||||||
|
level = "ERROR";
|
||||||
|
}
|
||||||
|
if (!samePath && facts.targetExists && !sourceGone) {
|
||||||
|
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
|
||||||
|
level = "ERROR";
|
||||||
|
}
|
||||||
|
if (level === "INFO" && dirReadFailed) {
|
||||||
|
// Datei da + Quelle weg, aber Schreibweise ungeprueft — KEIN stilles OK.
|
||||||
|
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
||||||
|
level = "WARN";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: level === "INFO",
|
||||||
|
level,
|
||||||
|
targetExists: facts.targetExists,
|
||||||
|
onDiskName,
|
||||||
|
nameMatches,
|
||||||
|
sourceGone,
|
||||||
|
targetSize: facts.targetSize,
|
||||||
|
reason: problems.join("; ")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifiziert NACH einem Rename SYNCHRON, ob das Ergebnis wirklich stimmt — der Kern
|
||||||
|
* der User-Anforderung ("nur weil er renaming sagt heisst es nicht das es klappt").
|
||||||
|
* Fuer die synchronen Rename-Sites (startup-Dedup, Suffix-Fix, Deobfuskation). Rein
|
||||||
|
* lesend, wirft nie. fs-Aufrufe ueber toLongPath (lange Windows-Pfade!). */
|
||||||
|
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
||||||
|
const longTarget = toLongPath(targetPath);
|
||||||
|
let targetExists = false;
|
||||||
|
let targetSize: number | null = null;
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(longTarget);
|
||||||
|
targetExists = true;
|
||||||
|
targetSize = stat.size;
|
||||||
|
} catch {
|
||||||
|
targetExists = false;
|
||||||
|
}
|
||||||
|
let dirEntries: string[] | null = null;
|
||||||
|
if (targetExists) {
|
||||||
|
try {
|
||||||
|
dirEntries = fs.readdirSync(path.dirname(longTarget));
|
||||||
|
} catch {
|
||||||
|
dirEntries = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sourceExists = false;
|
||||||
|
try {
|
||||||
|
fs.statSync(toLongPath(sourcePath));
|
||||||
|
sourceExists = true;
|
||||||
|
} catch {
|
||||||
|
sourceExists = false;
|
||||||
|
}
|
||||||
|
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asynchrone Verifikation — fuer den Media-Rename-Hot-Path (renamePathWithExdevFallback),
|
||||||
|
* damit KEIN synchrones statSync/readdirSync den Electron-Main-Loop in Saison-Pack-
|
||||||
|
* Rename-Schleifen blockiert (Projekt-Regel: kein sync I/O in Hot Paths). Wirft nie. */
|
||||||
|
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
||||||
|
const longTarget = toLongPath(targetPath);
|
||||||
|
let targetExists = false;
|
||||||
|
let targetSize: number | null = null;
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(longTarget);
|
||||||
|
targetExists = true;
|
||||||
|
targetSize = stat.size;
|
||||||
|
} catch {
|
||||||
|
targetExists = false;
|
||||||
|
}
|
||||||
|
let dirEntries: string[] | null = null;
|
||||||
|
if (targetExists) {
|
||||||
|
try {
|
||||||
|
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
|
||||||
|
} catch {
|
||||||
|
dirEntries = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sourceExists = false;
|
||||||
|
try {
|
||||||
|
await fs.promises.stat(toLongPath(sourcePath));
|
||||||
|
sourceExists = true;
|
||||||
|
} catch {
|
||||||
|
sourceExists = false;
|
||||||
|
}
|
||||||
|
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||||
|
}
|
||||||
@ -61,6 +61,7 @@ import type { RotationEvent } from "../shared/types";
|
|||||||
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
|
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 { logRenameEvent as writeRenameLogEvent } from "./rename-log";
|
import { logRenameEvent as writeRenameLogEvent } from "./rename-log";
|
||||||
|
import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerification } from "./desktop-rename-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";
|
||||||
|
|
||||||
@ -830,6 +831,17 @@ function isIgnorableEmptyDirFileName(fileName: string): boolean {
|
|||||||
return EMPTY_DIR_IGNORED_FILE_NAMES.has(normalized) || EMPTY_DIR_IGNORED_FILE_RE.test(normalized);
|
return EMPTY_DIR_IGNORED_FILE_NAMES.has(normalized) || EMPTY_DIR_IGNORED_FILE_RE.test(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kopfzeile fuer eine Rename-Verifikation im Desktop-Log (passend zum Level). */
|
||||||
|
function verifyHeadline(v: RenameVerification): string {
|
||||||
|
if (v.ok) {
|
||||||
|
return "Rename verifiziert";
|
||||||
|
}
|
||||||
|
if (v.level === "WARN") {
|
||||||
|
return "Rename vollzogen, aber Schreibweise nicht verifiziert";
|
||||||
|
}
|
||||||
|
return "Rename meldet OK, aber Verifikation FEHLGESCHLAGEN";
|
||||||
|
}
|
||||||
|
|
||||||
function toWindowsLongPathIfNeeded(filePath: string): string {
|
function toWindowsLongPathIfNeeded(filePath: string): string {
|
||||||
const absolute = path.resolve(String(filePath || ""));
|
const absolute = path.resolve(String(filePath || ""));
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
@ -2054,6 +2066,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
...(matchedBy ? { matchedBy } : {}),
|
...(matchedBy ? { matchedBy } : {}),
|
||||||
...fields
|
...fields
|
||||||
});
|
});
|
||||||
|
// Spiegeln ins Desktop-Rename-Log (luekenlose Sitzungs-Uebersicht beim User).
|
||||||
|
logDesktopRename(level, `[${stage}] ${message}`, {
|
||||||
|
paket: pkg.name,
|
||||||
|
...(item ? { datei: item.fileName } : {}),
|
||||||
|
...(matchedBy ? { matchedBy } : {}),
|
||||||
|
...fields
|
||||||
|
});
|
||||||
if (item) {
|
if (item) {
|
||||||
this.logItemOnly(item, level, message, {
|
this.logItemOnly(item, level, message, {
|
||||||
stage,
|
stage,
|
||||||
@ -3638,7 +3657,56 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async renamePathWithExdevFallback(sourcePath: string, targetPath: string): Promise<void> {
|
/** Verify+Log fuer SYNCHRONE Rename-Sites (startup-Dedup, Suffix-Fix, Deobfuskation),
|
||||||
|
* die nicht ueber renamePathWithExdevFallback laufen. Nach erfolgreichem renameSync
|
||||||
|
* aufrufen — verifiziert das On-Disk-Ergebnis und protokolliert es ins Desktop-Log. */
|
||||||
|
private logVerifiedRenameSync(label: string, sourcePath: string, targetPath: string, extraFields?: Record<string, unknown>): void {
|
||||||
|
const v = verifyRename(sourcePath, targetPath);
|
||||||
|
logDesktopRename(v.level, `${label}: ${verifyHeadline(v)}`, {
|
||||||
|
...(extraFields || {}),
|
||||||
|
source: path.basename(sourcePath),
|
||||||
|
requested: path.basename(targetPath),
|
||||||
|
onDisk: v.onDiskName,
|
||||||
|
targetDir: path.dirname(targetPath),
|
||||||
|
sourceGone: v.sourceGone,
|
||||||
|
sizeBytes: v.targetSize,
|
||||||
|
...(v.ok ? {} : { grund: v.reason })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifizierter Wrapper um jeden Media-Rename/-Move: protokolliert den Vorgang
|
||||||
|
* ins Desktop-Rename-Log UND verifiziert danach, dass die Datei wirklich unter
|
||||||
|
* dem Zielnamen auf der Platte liegt (Quelle weg, korrekte Schreibweise). Nur weil
|
||||||
|
* fs.rename "ok" meldet, ist der Rename noch nicht bewiesen. Verifikations-Fehler
|
||||||
|
* werden NUR geloggt (kein throw) — reine Beobachtbarkeit, keine Verhaltensaenderung. */
|
||||||
|
private async renamePathWithExdevFallback(sourcePath: string, targetPath: string, ctx?: { label?: string; fields?: Record<string, unknown> }): Promise<void> {
|
||||||
|
const label = ctx?.label || "rename";
|
||||||
|
try {
|
||||||
|
await this.renamePathWithExdevFallbackRaw(sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
logDesktopRename("ERROR", `${label}: Rename fehlgeschlagen`, {
|
||||||
|
...(ctx?.fields || {}),
|
||||||
|
source: path.basename(sourcePath),
|
||||||
|
sourceDir: path.dirname(sourcePath),
|
||||||
|
target: path.basename(targetPath),
|
||||||
|
error: compactErrorText(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const v = await verifyRenameAsync(sourcePath, targetPath);
|
||||||
|
logDesktopRename(v.level, `${label}: ${verifyHeadline(v)}`, {
|
||||||
|
...(ctx?.fields || {}),
|
||||||
|
source: path.basename(sourcePath),
|
||||||
|
requested: path.basename(targetPath),
|
||||||
|
onDisk: v.onDiskName,
|
||||||
|
targetDir: path.dirname(targetPath),
|
||||||
|
sourceGone: v.sourceGone,
|
||||||
|
sizeBytes: v.targetSize,
|
||||||
|
...(v.ok ? {} : { grund: v.reason })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renamePathWithExdevFallbackRaw(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
const sourceFsPath = toWindowsLongPathIfNeeded(sourcePath);
|
const sourceFsPath = toWindowsLongPathIfNeeded(sourcePath);
|
||||||
const targetFsPath = toWindowsLongPathIfNeeded(targetPath);
|
const targetFsPath = toWindowsLongPathIfNeeded(targetPath);
|
||||||
// Transient lock codes — antivirus scan, Windows Search Indexer, OneDrive
|
// Transient lock codes — antivirus scan, Windows Search Indexer, OneDrive
|
||||||
@ -3736,7 +3804,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.renamePathWithExdevFallback(sourceCompanionPath, targetCompanionPath);
|
await this.renamePathWithExdevFallback(sourceCompanionPath, targetCompanionPath, { label: "companion" });
|
||||||
logger.info(`Auto-Rename Companion: ${entryName} -> ${newCompanionName}`);
|
logger.info(`Auto-Rename Companion: ${entryName} -> ${newCompanionName}`);
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
this.logPackageForPackage(pkg, "INFO", "Auto-Rename Companion umbenannt", {
|
this.logPackageForPackage(pkg, "INFO", "Auto-Rename Companion umbenannt", {
|
||||||
@ -4346,7 +4414,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Route through renamePathWithExdevFallback so we get the long-path /
|
// Route through renamePathWithExdevFallback so we get the long-path /
|
||||||
// UNC handling AND the transient-error retry for free.
|
// UNC handling AND the transient-error retry for free.
|
||||||
try {
|
try {
|
||||||
await this.renamePathWithExdevFallback(sourcePath, targetPath);
|
await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename (Schreibweise)" });
|
||||||
renamedCount += 1;
|
renamedCount += 1;
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
const resolved = resolveRenameItem(targetPath);
|
const resolved = resolveRenameItem(targetPath);
|
||||||
@ -4418,7 +4486,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.renamePathWithExdevFallback(sourcePath, targetPath);
|
await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename" });
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
this.logPackageForPackage(pkg, "INFO", "Auto-Rename durchgeführt", {
|
this.logPackageForPackage(pkg, "INFO", "Auto-Rename durchgeführt", {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
@ -4455,7 +4523,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.renamePathWithExdevFallback(sourcePath, fallbackPath);
|
await this.renamePathWithExdevFallback(sourcePath, fallbackPath, { label: "auto-rename (Pfadlaenge-Fallback)" });
|
||||||
logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(fallbackPath)}`);
|
logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(fallbackPath)}`);
|
||||||
renamed += 1;
|
renamed += 1;
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
@ -4513,7 +4581,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async moveFileWithExdevFallback(sourcePath: string, targetPath: string): Promise<void> {
|
private async moveFileWithExdevFallback(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
await this.renamePathWithExdevFallback(sourcePath, targetPath);
|
await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "mkv-move" });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupNonMkvResidualFiles(rootDir: string, targetDir: string): Promise<number> {
|
private async cleanupNonMkvResidualFiles(rootDir: string, targetDir: string): Promise<number> {
|
||||||
@ -4615,7 +4683,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (await this.existsAsync(candidate)) {
|
if (await this.existsAsync(candidate)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.renamePathWithExdevFallback(targetPath, candidate);
|
await this.renamePathWithExdevFallback(targetPath, candidate, { label: "mkv-move (Konflikt-Aufloesung)" });
|
||||||
moved = true;
|
moved = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -6343,8 +6411,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
fs.renameSync(duplicateTargetPath, canonicalPath);
|
fs.renameSync(duplicateTargetPath, canonicalPath);
|
||||||
canonicalExists = true;
|
canonicalExists = true;
|
||||||
|
this.logVerifiedRenameSync("startup-dedup", duplicateTargetPath, canonicalPath);
|
||||||
logger.info(`startupDuplicateMerge: ${path.basename(duplicateTargetPath)} → ${canonicalBaseName}`);
|
logger.info(`startupDuplicateMerge: ${path.basename(duplicateTargetPath)} → ${canonicalBaseName}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logDesktopRename("ERROR", "startup-dedup: Rename fehlgeschlagen", {
|
||||||
|
source: path.basename(duplicateTargetPath),
|
||||||
|
target: canonicalBaseName,
|
||||||
|
error: compactErrorText(err)
|
||||||
|
});
|
||||||
logger.warn(`startupDuplicateMerge: Umbenennung fehlgeschlagen ${duplicateTargetPath}: ${compactErrorText(err)}`);
|
logger.warn(`startupDuplicateMerge: Umbenennung fehlgeschlagen ${duplicateTargetPath}: ${compactErrorText(err)}`);
|
||||||
}
|
}
|
||||||
} else if (duplicateExists && canonicalExists && primaryWins) {
|
} else if (duplicateExists && canonicalExists && primaryWins) {
|
||||||
@ -6358,8 +6432,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
fs.rmSync(canonicalPath, { force: true });
|
fs.rmSync(canonicalPath, { force: true });
|
||||||
fs.renameSync(duplicateTargetPath, canonicalPath);
|
fs.renameSync(duplicateTargetPath, canonicalPath);
|
||||||
canonicalExists = true;
|
canonicalExists = true;
|
||||||
|
this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath);
|
||||||
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
|
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", {
|
||||||
|
source: path.basename(duplicateTargetPath),
|
||||||
|
target: canonicalBaseName,
|
||||||
|
error: compactErrorText(err)
|
||||||
|
});
|
||||||
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
|
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6971,6 +7051,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fs.promises.rename(filePath, newPath);
|
await fs.promises.rename(filePath, newPath);
|
||||||
|
this.logVerifiedRenameSync("deobfuskation", filePath, newPath, { paket: pkg.name, signatur: sig });
|
||||||
item.fileName = newName;
|
item.fileName = newName;
|
||||||
item.targetPath = newPath;
|
item.targetPath = newPath;
|
||||||
// Update the path lookup
|
// Update the path lookup
|
||||||
@ -6984,6 +7065,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
signature: sig
|
signature: sig
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logDesktopRename("ERROR", "deobfuskation: Rename fehlgeschlagen", {
|
||||||
|
source: path.basename(filePath),
|
||||||
|
paket: pkg.name,
|
||||||
|
error: compactErrorText(err as Error)
|
||||||
|
});
|
||||||
logger.warn(`Deobfuskation fehlgeschlagen: ${filePath}: ${compactErrorText(err as Error)}`);
|
logger.warn(`Deobfuskation fehlgeschlagen: ${filePath}: ${compactErrorText(err as Error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7146,6 +7232,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.renameSync(tp, originalPath);
|
fs.renameSync(tp, originalPath);
|
||||||
|
this.logVerifiedRenameSync("suffix-fix", tp, originalPath);
|
||||||
this.reservedTargetPaths.delete(pathKey(tp));
|
this.reservedTargetPaths.delete(pathKey(tp));
|
||||||
this.reservedTargetPaths.set(originalKey, item.id);
|
this.reservedTargetPaths.set(originalKey, item.id);
|
||||||
this.claimedTargetPathByItem.set(item.id, originalPath);
|
this.claimedTargetPathByItem.set(item.id, originalPath);
|
||||||
@ -7154,6 +7241,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
fixed += 1;
|
fixed += 1;
|
||||||
logger.info(`fixDuplicateSuffix: ${path.basename(tp)} → ${originalName}`);
|
logger.info(`fixDuplicateSuffix: ${path.basename(tp)} → ${originalName}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logDesktopRename("ERROR", "suffix-fix: Rename fehlgeschlagen", {
|
||||||
|
source: path.basename(tp),
|
||||||
|
target: originalName,
|
||||||
|
error: compactErrorText(err)
|
||||||
|
});
|
||||||
logger.warn(`fixDuplicateSuffix: Umbenennung fehlgeschlagen ${tp}: ${compactErrorText(err)}`);
|
logger.warn(`fixDuplicateSuffix: Umbenennung fehlgeschlagen ${tp}: ${compactErrorText(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { getDebugSetupCheck } from "./debug-setup";
|
|||||||
import { getLogFilePath } from "./logger";
|
import { getLogFilePath } from "./logger";
|
||||||
import { getPackageLogPath } from "./package-log";
|
import { getPackageLogPath } from "./package-log";
|
||||||
import { getRenameLogPath } from "./rename-log";
|
import { getRenameLogPath } from "./rename-log";
|
||||||
|
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||||
@ -181,6 +182,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
|
|||||||
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
||||||
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
||||||
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
||||||
|
addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
||||||
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
||||||
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||||
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||||
|
|||||||
@ -135,3 +135,25 @@ Streak (3× hintereinander leer → geparkt), nicht der einmalige Wortlaut.
|
|||||||
Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt
|
Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt
|
||||||
(`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt
|
(`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt
|
||||||
unbewiesen, dass der Produktionspfad das Signal überhaupt setzt.
|
unbewiesen, dass der Produktionspfad das Signal überhaupt setzt.
|
||||||
|
|
||||||
|
## 2026-06-01 — Ein Verifizierer muss dieselbe Pfad-Normalisierung nutzen wie die verifizierte Operation
|
||||||
|
|
||||||
|
**Muster:** Neues Renaming-Logging sollte nach jedem Rename verifizieren, ob die Datei
|
||||||
|
wirklich unter dem Zielnamen liegt. `verifyRename` machte statSync/readdirSync auf den
|
||||||
|
ROHEN Pfaden — der echte Rename lief aber über `toWindowsLongPathIfNeeded` (\?\-Prefix
|
||||||
|
ab >=248 Zeichen). Bei langen Scene-Release-Pfaden (genau das, was die App routinemäßig
|
||||||
|
umbenennt) scheiterten die rohen fs-Calls → falsches „Ziel nicht gefunden" UND — schlimmer —
|
||||||
|
die Quell-Prüfung scheiterte ebenfalls → `sourceGone` fälschlich true → **falsches „OK"**,
|
||||||
|
das einen halb-fertigen Verschiebevorgang maskiert. Der Diagnose-Log hätte genau die
|
||||||
|
schwersten Fälle vergiftet. (Adversarialer Review-Workflow fand es, Confidence 0.8.)
|
||||||
|
|
||||||
|
**Regel:** Wenn Code eine Operation VERIFIZIERT, muss er exakt dieselbe Pfad-/Encoding-/
|
||||||
|
Normalisierung verwenden wie die Operation selbst (hier: \?\-Long-Path-Prefix). Sonst
|
||||||
|
mis-reportet der Verifizierer still — und am verlässlichsten bei den Edge-Cases, die man
|
||||||
|
eigentlich fangen wollte. Ein falsches OK in einem Diagnose-Log ist schlimmer als ein
|
||||||
|
falsches ERROR. Zusatz: readdir-Fehler darf nicht zu „Schreibweise ok" degradieren
|
||||||
|
(stilles False-OK) → eigenes WARN-Level „nicht verifizierbar".
|
||||||
|
|
||||||
|
**Meta:** Bei einem Feature, dessen ganzer Zweck Beobachtbarkeit/Verifikation ist, lohnt
|
||||||
|
ein adversarialer Review mit Fokus „würde die Verifikation auf der ECHTEN Last (lange
|
||||||
|
Pfade, case-insensitive FS, EXDEV) korrekt urteilen?" — nicht nur „kompiliert + Happy-Path-Test".
|
||||||
|
|||||||
129
tests/desktop-rename-log.test.ts
Normal file
129
tests/desktop-rename-log.test.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
getDesktopRenameLogPath,
|
||||||
|
initDesktopRenameLog,
|
||||||
|
logDesktopRename,
|
||||||
|
shutdownDesktopRenameLog,
|
||||||
|
verifyRename
|
||||||
|
} from "../src/main/desktop-rename-log";
|
||||||
|
|
||||||
|
const createdTmpDirs: string[] = [];
|
||||||
|
|
||||||
|
function tmpDesktop(): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-"));
|
||||||
|
createdTmpDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
shutdownDesktopRenameLog();
|
||||||
|
for (const dir of createdTmpDirs) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createdTmpDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("desktop-rename-log", () => {
|
||||||
|
it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => {
|
||||||
|
const desktop = tmpDesktop();
|
||||||
|
initDesktopRenameLog(desktop);
|
||||||
|
|
||||||
|
const logPath = getDesktopRenameLogPath();
|
||||||
|
expect(logPath).toBeTruthy();
|
||||||
|
expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true);
|
||||||
|
expect(fs.existsSync(logPath as string)).toBe(true);
|
||||||
|
|
||||||
|
logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" });
|
||||||
|
const content = fs.readFileSync(logPath as string, "utf8");
|
||||||
|
expect(content).toContain("Rename-Session gestartet");
|
||||||
|
expect(content).toContain("Test-Rename");
|
||||||
|
expect(content).toContain("source=a.mkv");
|
||||||
|
expect(content).toContain("requested=b.mkv");
|
||||||
|
expect(content).toMatch(/\[INFO\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
|
||||||
|
// Genau die User-Anforderung: Ordner zur Laufzeit geloescht -> beim naechsten
|
||||||
|
// Rename automatisch wieder da, selbst wenn das Programm offen ist.
|
||||||
|
const desktop = tmpDesktop();
|
||||||
|
initDesktopRenameLog(desktop);
|
||||||
|
const logPath = getDesktopRenameLogPath() as string;
|
||||||
|
logDesktopRename("INFO", "ZeileA");
|
||||||
|
|
||||||
|
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
|
||||||
|
expect(fs.existsSync(logPath)).toBe(false);
|
||||||
|
|
||||||
|
// Naechster Vorgang muss Ordner UND Datei (mit Header) selbstheilend neu anlegen.
|
||||||
|
logDesktopRename("INFO", "ZeileB");
|
||||||
|
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
|
||||||
|
expect(fs.existsSync(logPath)).toBe(true);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(logPath, "utf8");
|
||||||
|
expect(content).toContain("Rename-Session gestartet");
|
||||||
|
expect(content).toContain("ZeileB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a silent no-op when initialized without a desktop path (never throws)", () => {
|
||||||
|
initDesktopRenameLog("");
|
||||||
|
expect(getDesktopRenameLogPath()).toBeNull();
|
||||||
|
expect(() => logDesktopRename("INFO", "egal")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifyRename: ok when the target exists under the exact name and the source is gone", () => {
|
||||||
|
const dir = tmpDesktop();
|
||||||
|
const source = path.join(dir, "scn-xyz.part1.rar");
|
||||||
|
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
|
||||||
|
fs.writeFileSync(target, "data"); // Post-Rename-Zustand: Ziel da, Quelle weg.
|
||||||
|
|
||||||
|
const v = verifyRename(source, target);
|
||||||
|
expect(v.ok).toBe(true);
|
||||||
|
expect(v.level).toBe("INFO");
|
||||||
|
expect(v.targetExists).toBe(true);
|
||||||
|
expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar");
|
||||||
|
expect(v.nameMatches).toBe(true);
|
||||||
|
expect(v.sourceGone).toBe(true);
|
||||||
|
expect(v.targetSize).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifyRename: FAILS when the target is missing although rename reported success", () => {
|
||||||
|
const dir = tmpDesktop();
|
||||||
|
const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar"));
|
||||||
|
expect(v.ok).toBe(false);
|
||||||
|
expect(v.level).toBe("ERROR");
|
||||||
|
expect(v.targetExists).toBe(false);
|
||||||
|
expect(v.reason).toMatch(/nicht gefunden/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
|
||||||
|
// EXDEV-Copy gelang, aber rm(source) schlug fehl -> Ziel da, Quelle auch noch.
|
||||||
|
const dir = tmpDesktop();
|
||||||
|
const source = path.join(dir, "src.rar");
|
||||||
|
const target = path.join(dir, "dst.rar");
|
||||||
|
fs.writeFileSync(source, "x");
|
||||||
|
fs.writeFileSync(target, "x");
|
||||||
|
|
||||||
|
const v = verifyRename(source, target);
|
||||||
|
expect(v.ok).toBe(false);
|
||||||
|
expect(v.level).toBe("ERROR");
|
||||||
|
expect(v.sourceGone).toBe(false);
|
||||||
|
expect(v.reason).toMatch(/Quelldatei existiert noch/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => {
|
||||||
|
const dir = tmpDesktop();
|
||||||
|
const p = path.join(dir, "file.mkv");
|
||||||
|
fs.writeFileSync(p, "x");
|
||||||
|
|
||||||
|
const v = verifyRename(p, p);
|
||||||
|
expect(v.ok).toBe(true);
|
||||||
|
expect(v.targetExists).toBe(true);
|
||||||
|
expect(v.nameMatches).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user