Renaming 100%: collect leitet sauberen Namen selbst ab (gemeinsame Entscheidungsfunktion + Wurzel-Schutz)

User-Report (aus dem Desktop-Rename-Log): 17 Dateien landeten ROH in der Library
("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv") — Auto-Rename hatte sie verpasst, der
MKV-Collect schob sie mit dem rohen Scene-Namen weg.

Root Cause 1: Auto-Rename und collectMkvFilesToLibrary sind entkoppelte Scans. Auto-Rename
benennt nur present-and-stable Dateien in extractDir um; eine verpasste Datei (verpasster
Zyklus ODER lag in "Downloader Unfertig" ausserhalb extractDir) wurde von collect roh
weggeschoben (collect behielt blind den Basename).
Root Cause 2: decideAutoRenameBaseName fabrizierte Namen fuer token-lose generische Ordner
("Mega-Direct-Pack" -> "Mega-Direct-Pack.S01E01") wegen eines hasSceneGroupSuffix-Falsch-
Positivs auf "-Pack" — derselbe latente Bug haette Auto-Rename getroffen.

Fix:
- Namens-Entscheidung in EINE pure Funktion extrahiert: decideAutoRenameBaseName (Single
  Source of Truth fuer Auto-Rename UND Collect — koennen nicht mehr divergieren).
- Wurzel-Schutz darin: Rename nur, wenn ein folderCandidate einen echten Season-/Episode-
  Token traegt (kein Fabrizieren aus token-losen Ordnern). Fixt beide Pfade.
- collectMkvFilesToLibrary leitet den sauberen Namen via dieser Funktion ab (gegated auf
  autoRename4sf4sj — respektiert die Umbenenn-Einstellung), inkl. Companion-Untertitel und
  Dedup gegen den sauberen Namen. mkvFiles traegt jetzt sourceRoot fuer die Ordner-Herleitung.
- Auto-Rename-Loop nutzt jetzt die gemeinsame Funktion (behebt nebenbei 2 latente
  use-before-declaration/TDZ-Fehler an resolveRenameItem).
- Latenter Bug: Casing-Zaehler renamedCount -> renamed (war undeklariert -> ReferenceError,
  vom catch verschluckt -> Casing-Korrekturen wurden still verworfen).

Verifiziert: tsc 6 (von 9 — 3 latente Fehler nebenbei behoben), 678 Tests + 9 neue (7
Charakterisierung der Entscheidung + 2 Collect-Integration: raw->clean + Companion/.srt folgt
+ Datei ausserhalb extractDir), Build gruen. Adversarialer Review-Workflow (4 Linsen) + Advisor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-06-03 01:09:21 +02:00
parent 9a71e01417
commit 288a0762a6
4 changed files with 541 additions and 138 deletions

View File

@ -1407,6 +1407,107 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions(
return null; return null;
} }
/** Final auto-rename naming DECISION for one video file. Factored out of
* autoRenameExtractedVideoFilesImpl so the MKV-collect stage can reuse the
* IDENTICAL derivation + guards single source of truth, so the two can never
* drift (that drift is exactly why files auto-rename missed landed raw in the
* library). Pure: no fs, no logging, no chainPackageFileOp. Returns either the
* clean target base name to rename to, or a skip with the reason (the caller
* logs and falls back to KEEPING the current name raw-keep is the floor). */
export type AutoRenameNameDecision =
| { kind: "rename"; baseName: string; note?: "token-inserted" | "token-applyToken" | "folder-override"; sourceEpisodeToken?: string; targetEpisodeToken?: string }
| { kind: "skip"; reason: "no-target" | "source-better" | "token-loss" | "token-mismatch"; targetBaseName?: string; sourceEpisodeToken?: string; targetEpisodeToken?: string };
export function decideAutoRenameBaseName(
folderCandidates: string[],
sourceName: string,
sourceBaseName: string,
parentFolderName: string,
extractDirName: string
): AutoRenameNameDecision {
let targetBaseName = buildAutoRenameBaseNameFromFoldersWithOptions(folderCandidates, sourceBaseName, {
forceEpisodeForSeasonFolder: true
});
// Wurzel-Schutz gegen Namens-Fabrikation: produziert der Helper einen Namen, OBWOHL KEIN
// folderCandidate einen Season-/Episode-Token traegt (z.B. ein generischer Paketname wie
// "Mega-Direct-Pack", den hasSceneGroupSuffix faelschlich als Scene-Gruppe wertet), stammt
// die Episode rein aus dem QUELLnamen und wird an einen token-losen Ordner angehaengt
// ("Mega-Direct-Pack.S01E01"). Ein Ordner ohne Season/Episode kann eine Episode nicht
// autoritativ benennen → kein Rename. Schuetzt Auto-Rename UND den MKV-Collect an der Wurzel.
if (targetBaseName) {
const anyFolderHasSeasonOrEpisode = folderCandidates.some(
(folderName) => Boolean(extractSeasonToken(folderName)) || Boolean(extractEpisodeToken(folderName))
);
if (!anyFolderHasSeasonOrEpisode) {
return { kind: "skip", reason: "no-target" };
}
}
// Guard A — degenerate folder layout: when the computed target would DISCARD a
// well-formed source (source has SxxExx + a real series prefix, target lost the
// prefix and is much shorter), keep the source. Renaming would destroy info.
if (targetBaseName && sourceBaseName.length > 0) {
const sourceHasEpisode = Boolean(extractEpisodeToken(sourceBaseName));
const targetHasEpisode = Boolean(extractEpisodeToken(targetBaseName));
const sourceHasSeriesPrefix = hasMeaningfulSeriesPrefix(sourceBaseName);
const targetHasSeriesPrefix = hasMeaningfulSeriesPrefix(targetBaseName);
const targetIsMuchShorter = targetBaseName.length * 2 < sourceBaseName.length;
if (sourceHasEpisode && targetHasEpisode && sourceHasSeriesPrefix && !targetHasSeriesPrefix && targetIsMuchShorter) {
return { kind: "skip", reason: "source-better", targetBaseName };
}
}
// Guard B — never strip or mislabel a valid SxxExx token from the source.
let note: "token-inserted" | "token-applyToken" | "folder-override" | undefined;
const sourceEpisodeToken = extractEpisodeToken(sourceBaseName);
if (targetBaseName && sourceEpisodeToken) {
const targetEpisodeToken = extractEpisodeToken(targetBaseName);
if (!targetEpisodeToken) {
const insertedEpisode = targetBaseName.replace(
/(^|[._\-\s])(s\d{1,2})(?=[A-Za-z0-9])/i,
`$1${sourceEpisodeToken}.`
);
if (insertedEpisode !== targetBaseName && extractEpisodeToken(insertedEpisode)) {
targetBaseName = insertedEpisode;
note = "token-inserted";
} else {
const repaired = applyEpisodeTokenToFolderName(targetBaseName, sourceEpisodeToken);
if (repaired && extractEpisodeToken(repaired)) {
targetBaseName = repaired;
note = "token-applyToken";
} else {
return { kind: "skip", reason: "token-loss", targetBaseName, sourceEpisodeToken };
}
}
} else if (targetEpisodeToken !== sourceEpisodeToken) {
// Target has a DIFFERENT episode token than source. Trust the folder ONLY when
// the source filename looks obfuscated (anti-piracy scramble) AND the immediate
// parent folder carries the same explicit token as the computed target. A clean
// scene source must NEVER be overridden (a one-off folder mismatch means the
// FOLDER is wrong, not the file).
const parentEpisodeToken = extractEpisodeToken(parentFolderName);
const sourceLooksObfuscated = looksLikeObfuscatedSceneFileName(sourceName);
const folderIsAuthoritative = Boolean(
parentEpisodeToken
&& parentEpisodeToken === targetEpisodeToken
&& parentFolderName.toLowerCase() !== extractDirName.toLowerCase()
&& sourceLooksObfuscated
);
if (folderIsAuthoritative) {
note = "folder-override";
return { kind: "rename", baseName: targetBaseName, note, sourceEpisodeToken, targetEpisodeToken };
}
return { kind: "skip", reason: "token-mismatch", targetBaseName, sourceEpisodeToken, targetEpisodeToken };
}
}
if (!targetBaseName) {
return { kind: "skip", reason: "no-target" };
}
return { kind: "rename", baseName: targetBaseName, note };
}
// Hoisted regex patterns — avoid recompiling on every resolveArchiveItemsFromList() call. // Hoisted regex patterns — avoid recompiling on every resolveArchiveItemsFromList() call.
const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/;
const ARCHIVE_RAR_RE = /^(.*)\.rar$/; const ARCHIVE_RAR_RE = /^(.*)\.rar$/;
@ -4183,141 +4284,85 @@ export class DownloadManager extends EventEmitter {
folderCandidates.push(extra); folderCandidates.push(extra);
} }
} }
let targetBaseName = buildAutoRenameBaseNameFromFoldersWithOptions(folderCandidates, sourceBaseName, { // Naming-Entscheidung ueber die GEMEINSAME Funktion (decideAutoRenameBaseName) —
forceEpisodeForSeasonFolder: true // exakt dieselbe, die der MKV-Collect nutzt. Single source of truth: ein Guard-Fix
}); // gilt damit immer fuer BEIDE Pfade. Frueher lag die Logik nur hier inline, der
// Defense against degenerate folder layouts: when the immediate parent // Collect hatte keine → von Auto-Rename verpasste Dateien landeten roh in der Library.
// folder lacks a real series name (e.g. "S01 Complete", "Season 1", const decision = decideAutoRenameBaseName(
// "Staffel 02"), buildAutoRenameBaseName can collapse a perfect source folderCandidates,
// filename like "Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB- sourceName,
// DL.AC3.h264" into garbage like "S01E01 Complete". If the source is sourceBaseName,
// already well-formed (has SxxExx + a meaningful series-name prefix) path.basename(path.dirname(sourcePath)),
// and the computed target is much shorter / lacks that prefix, keep path.basename(extractDir)
// the source as-is — renaming would actively destroy information. );
if (targetBaseName && sourceBaseName.length > 0) { // Skip-Branches behalten den BERECHNETEN Zielnamen fuer die Log-Attribution
const sourceHasEpisode = Boolean(extractEpisodeToken(sourceBaseName)); // (resolveRenameItem speist ihn in inferItemForMediaLog) — wie im Original; no-target
const targetHasEpisode = Boolean(extractEpisodeToken(targetBaseName)); // bleibt null, damit der `if (!targetBaseName)`-Zweig weiter greift.
const sourceHasSeriesPrefix = hasMeaningfulSeriesPrefix(sourceBaseName); let targetBaseName: string | null = decision.kind === "rename" ? decision.baseName : (decision.targetBaseName ?? null);
const targetHasSeriesPrefix = hasMeaningfulSeriesPrefix(targetBaseName);
const targetIsMuchShorter = targetBaseName.length * 2 < sourceBaseName.length;
if (sourceHasEpisode
&& targetHasEpisode
&& sourceHasSeriesPrefix
&& !targetHasSeriesPrefix
&& targetIsMuchShorter) {
logger.info(`Auto-Rename uebersprungen: Source "${sourceBaseName}" ist bereits aussagekraeftiger als computed Target "${targetBaseName}"`);
if (pkg) {
const resolved = resolveRenameItem();
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename uebersprungen: Source schon besser als computed Target", {
sourcePath,
sourceName,
sourceBaseName,
targetBaseName,
folders: folderCandidates.join(", ")
}, resolved.item, resolved.matchedBy);
}
continue;
}
}
const resolveRenameItem = (...extra: Array<string | null | undefined>): { item: DownloadItem | null; matchedBy: string | null } => { const resolveRenameItem = (...extra: Array<string | null | undefined>): { item: DownloadItem | null; matchedBy: string | null } => {
if (!pkg) { if (!pkg) {
return { item: null, matchedBy: null }; return { item: null, matchedBy: null };
} }
return this.inferItemForMediaLog(pkg, sourcePath, sourceName, folderCandidates.join(" "), targetBaseName || "", ...extra); return this.inferItemForMediaLog(pkg, sourcePath, sourceName, folderCandidates.join(" "), targetBaseName || "", ...extra);
}; };
// SAFETY NET: Never strip a valid SxxExx token from the source filename. if (decision.kind === "skip" && decision.reason === "source-better") {
// If the source already has an episode token but the computed target lost it logger.info(`Auto-Rename uebersprungen: Source "${sourceBaseName}" ist bereits aussagekraeftiger als computed Target "${decision.targetBaseName}"`);
// (e.g. malformed package name "S01GERMAN" with no separator), preserve the if (pkg) {
// episode by either inserting it into the target or skipping the rename entirely. const resolved = resolveRenameItem();
// Without this guard, all episodes from a malformed pack collapse to one name this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename uebersprungen: Source schon besser als computed Target", {
// and collide with (2)(3)(4) suffixes in the MKV library. sourcePath,
const sourceEpisodeToken = extractEpisodeToken(sourceBaseName); sourceName,
if (targetBaseName && sourceEpisodeToken) { sourceBaseName,
const targetEpisodeToken = extractEpisodeToken(targetBaseName); targetBaseName: decision.targetBaseName,
if (!targetEpisodeToken) { folders: folderCandidates.join(", ")
// Try to insert the source's episode token: replace "Sxx<garbage>" with "SxxExx.<garbage>" }, resolved.item, resolved.matchedBy);
const insertedEpisode = targetBaseName.replace( }
/(^|[._\-\s])(s\d{1,2})(?=[A-Za-z0-9])/i, continue;
`$1${sourceEpisodeToken}.` }
); if (decision.kind === "skip" && decision.reason === "token-loss") {
if (insertedEpisode !== targetBaseName && extractEpisodeToken(insertedEpisode)) { logger.warn(`Auto-Rename Safety: Skipping rename - target wuerde Episode-Token verlieren (source=${sourceBaseName}, target=${decision.targetBaseName})`);
logger.info(`Auto-Rename Safety: Episode-Token in Target eingefuegt: ${targetBaseName} -> ${insertedEpisode}`); if (pkg) {
targetBaseName = insertedEpisode; const resolved = resolveRenameItem();
} else { this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token wuerde verloren gehen", {
const repaired = applyEpisodeTokenToFolderName(targetBaseName, sourceEpisodeToken); sourcePath,
if (repaired && extractEpisodeToken(repaired)) { sourceName,
logger.info(`Auto-Rename Safety: Episode-Token via applyToken: ${targetBaseName} -> ${repaired}`); sourceEpisodeToken: decision.sourceEpisodeToken,
targetBaseName = repaired; targetBaseName: decision.targetBaseName
} else { }, resolved.item, resolved.matchedBy);
logger.warn(`Auto-Rename Safety: Skipping rename - target wuerde Episode-Token verlieren (source=${sourceBaseName}, target=${targetBaseName})`); }
if (pkg) { continue;
const resolved = resolveRenameItem(); }
this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token wuerde verloren gehen", { if (decision.kind === "skip" && decision.reason === "token-mismatch") {
sourcePath, logger.warn(`Auto-Rename Safety: Skipping rename - Episode-Token Mismatch (source=${decision.sourceEpisodeToken}, target=${decision.targetEpisodeToken})`);
sourceName, if (pkg) {
sourceEpisodeToken, const resolved = resolveRenameItem();
targetBaseName this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token Mismatch", {
}, resolved.item, resolved.matchedBy); sourcePath,
} sourceName,
continue; sourceEpisodeToken: decision.sourceEpisodeToken,
} targetEpisodeToken: decision.targetEpisodeToken,
} targetBaseName: decision.targetBaseName
} else if (targetEpisodeToken !== sourceEpisodeToken) { }, resolved.item, resolved.matchedBy);
// Target has a DIFFERENT episode token than source. Normally that's a }
// sign the rename would mislabel the episode — BUT scene releases continue;
// often place obfuscated MKVs (e.g. "awa-diethundermans02e16hd.mkv" }
// = scrambled E16) inside an explicitly named episode folder if (decision.kind === "rename" && decision.note === "token-inserted") {
// (e.g. "Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"). logger.info(`Auto-Rename Safety: Episode-Token in Target eingefuegt -> ${decision.baseName}`);
// The folder is created by the release group with the REAL episode } else if (decision.kind === "rename" && decision.note === "token-applyToken") {
// info; the file name is anti-piracy obfuscation. So when the logger.info(`Auto-Rename Safety: Episode-Token via applyToken -> ${decision.baseName}`);
// immediate parent folder carries the same explicit SxxExx token as } else if (decision.kind === "rename" && decision.note === "folder-override") {
// our computed targetBaseName, trust the folder and override the const parentFolderName = path.basename(path.dirname(sourcePath));
// misleading source token. logger.info(`Auto-Rename: source-Token ${decision.sourceEpisodeToken} ignoriert, Folder-Token ${decision.targetEpisodeToken} ist authoritativ (vermutlich obfuskierter Dateiname in ${parentFolderName})`);
const parentFolderName = path.basename(path.dirname(sourcePath)); if (pkg) {
const parentEpisodeToken = extractEpisodeToken(parentFolderName); const resolved = resolveRenameItem();
// GUARD: only let the folder override the source token when the this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename: Folder-Token uebersteuert obfuskierten Datei-Token", {
// source filename actually LOOKS obfuscated (no scene markers like sourcePath,
// 720p / german / x264 / bluray, no dot-separated structure). sourceName,
// A clean scene release filename — e.g. "the.royals.2015.s01e09. sourceEpisodeToken: decision.sourceEpisodeToken,
// german.dl.720p.bluray.x264-j4f.mkv" — must NEVER be overridden, targetEpisodeToken: decision.targetEpisodeToken,
// because a one-off folder/file mismatch with a clean source means parentFolder: parentFolderName,
// the FOLDER is wrong, not the file. Renaming a real S01E09 to targetBaseName: decision.baseName
// S01E08 because the folder happens to say E08 would corrupt data. }, resolved.item, resolved.matchedBy);
const sourceLooksObfuscated = looksLikeObfuscatedSceneFileName(sourceName);
const folderIsAuthoritative = Boolean(
parentEpisodeToken
&& parentEpisodeToken === targetEpisodeToken
&& parentFolderName.toLowerCase() !== path.basename(extractDir).toLowerCase()
&& sourceLooksObfuscated
);
if (folderIsAuthoritative) {
logger.info(`Auto-Rename: source-Token ${sourceEpisodeToken} ignoriert, Folder-Token ${targetEpisodeToken} ist authoritativ (vermutlich obfuskierter Dateiname in ${parentFolderName})`);
if (pkg) {
const resolved = resolveRenameItem();
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename: Folder-Token uebersteuert obfuskierten Datei-Token", {
sourcePath,
sourceName,
sourceEpisodeToken,
targetEpisodeToken,
parentFolder: parentFolderName,
targetBaseName
}, resolved.item, resolved.matchedBy);
}
// Fall through to the normal rename path with targetBaseName.
} else {
logger.warn(`Auto-Rename Safety: Skipping rename - Episode-Token Mismatch (source=${sourceEpisodeToken}, target=${targetEpisodeToken})`);
if (pkg) {
const resolved = resolveRenameItem();
this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token Mismatch", {
sourcePath,
sourceName,
sourceEpisodeToken,
targetEpisodeToken,
targetBaseName
}, resolved.item, resolved.matchedBy);
}
continue;
}
} }
} }
if (!targetBaseName) { if (!targetBaseName) {
@ -4415,7 +4460,7 @@ export class DownloadManager extends EventEmitter {
// 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, { label: "auto-rename (Schreibweise)" }); await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename (Schreibweise)" });
renamedCount += 1; renamed += 1;
if (pkg) { if (pkg) {
const resolved = resolveRenameItem(targetPath); const resolved = resolveRenameItem(targetPath);
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename (Casing korrigiert)", { this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename (Casing korrigiert)", {
@ -4737,8 +4782,77 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
private async buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set<string>): Promise<string> { /** Baut die folderCandidates fuer die Namensherleitung im MKV-Collect identisch
* zu autoRenameExtractedVideoFilesImpl (Walk vom Datei-Verzeichnis hoch innerhalb des
* sourceRoot), aber zusaetzlich mit dem sourceRoot-Basename selbst (fuer Dateien direkt
* im Staffel-/Paketordner, wo der Walk nichts liefert) und dem outputDir-Basename. */
private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] {
const folderCandidates: string[] = [];
let currentDir = path.dirname(sourcePath);
while (currentDir && isPathInsideDir(currentDir, sourceRoot)) {
folderCandidates.push(path.basename(currentDir));
const parent = path.dirname(currentDir);
if (!parent || parent === currentDir) {
break;
}
currentDir = parent;
}
const seen = new Set(folderCandidates.map((c) => c.toLowerCase()));
const rootBase = path.basename(sourceRoot || "");
if (rootBase && !seen.has(rootBase.toLowerCase())) {
folderCandidates.push(rootBase);
seen.add(rootBase.toLowerCase());
}
const outputBase = path.basename(pkg.outputDir || "");
if (outputBase && !seen.has(outputBase.toLowerCase())) {
folderCandidates.push(outputBase);
}
return folderCandidates;
}
/** Leitet den SAUBEREN Library-Dateinamen fuer eine zu sammelnde MKV ab ueber die
* IDENTISCHE Entscheidung wie Auto-Rename (decideAutoRenameBaseName). Liefert null,
* wenn keine Verbesserung moeglich ist (raw-keep = Boden, nie schlechter als heute). */
private deriveCleanCollectFileName(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string | null {
// Respektiere die Umbenennungs-Einstellung: ist Auto-Rename AUS, benennt der Collect
// auch nicht um (Konsistenz — sonst wuerde ein Nutzer, der Umbenennen bewusst aus hat,
// trotzdem umbenannte Library-Dateien bekommen). Auto-Rename selbst ist an derselben
// Einstellung gegated (autoRenameExtractedVideoFilesImpl: if (!autoRename4sf4sj) return 0).
if (!this.settings.autoRename4sf4sj) {
return null;
}
const parsed = path.parse(path.basename(sourcePath)); const parsed = path.parse(path.basename(sourcePath));
const sourceExt = parsed.ext || ".mkv";
// Kein separater Quell-Guard noetig: decideAutoRenameBaseName liefert nur dann ein Rename,
// wenn ein folderCandidate einen echten Season-/Episode-Token traegt (Wurzel-Schutz dort) —
// generische Paketordner ("Mega-Direct-Pack") fuehren zu no-target → Roh-Name bleibt.
const folderCandidates = this.buildCollectFolderCandidates(sourcePath, sourceRoot, pkg);
const decision = decideAutoRenameBaseName(
folderCandidates,
path.basename(sourcePath),
parsed.name,
path.basename(path.dirname(sourcePath)),
path.basename(sourceRoot || "")
);
if (decision.kind !== "rename") {
return null;
}
const cleanBase = sanitizeFilename(decision.baseName);
if (!cleanBase) {
return null;
}
const cleanFileName = `${cleanBase}${sourceExt}`;
if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) {
return null; // already clean — no-op
}
return cleanFileName;
}
private async buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set<string>, desiredFileName?: string): Promise<string> {
// desiredFileName: der bereits abgeleitete SAUBERE Zielname (mit Endung). Wird er
// uebergeben, hat er Vorrang vor dem (evtl. rohen) Quell-Basename — so landet eine
// Datei, die Auto-Rename verpasst hat, trotzdem sauben benannt in der Library.
const parsed = path.parse(desiredFileName && desiredFileName.trim() ? desiredFileName : path.basename(sourcePath));
const extension = parsed.ext || ".mkv"; const extension = parsed.ext || ".mkv";
const baseName = sanitizeFilename(parsed.name || "video"); const baseName = sanitizeFilename(parsed.name || "video");
@ -4878,7 +4992,7 @@ export class DownloadManager extends EventEmitter {
// - Bonus-Dateinamen (Making-Of, Deleted-Scene, etc.) // - Bonus-Dateinamen (Making-Of, Deleted-Scene, etc.)
const sampleDirNames = new Set(["sample", "samples"]); const sampleDirNames = new Set(["sample", "samples"]);
const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i; const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i;
const mkvFiles: string[] = []; const mkvFiles: { filePath: string; sourceRoot: string }[] = [];
let sampleSkipped = 0; let sampleSkipped = 0;
let bonusSkipped = 0; let bonusSkipped = 0;
for (const { filePath, sourceRoot } of collected) { for (const { filePath, sourceRoot } of collected) {
@ -4896,7 +5010,7 @@ export class DownloadManager extends EventEmitter {
logger.info(`MKV-Sammelordner: Bonus-Datei uebersprungen: ${path.basename(filePath)} (Pfad: ${path.relative(sourceRoot, filePath)})`); logger.info(`MKV-Sammelordner: Bonus-Datei uebersprungen: ${path.basename(filePath)} (Pfad: ${path.relative(sourceRoot, filePath)})`);
continue; continue;
} }
mkvFiles.push(filePath); mkvFiles.push({ filePath, sourceRoot });
} }
if (sampleSkipped > 0) { if (sampleSkipped > 0) {
logger.info(`MKV-Sammelordner: pkg=${pkg.name}, ${sampleSkipped} Sample-MKV(s) übersprungen`); logger.info(`MKV-Sammelordner: pkg=${pkg.name}, ${sampleSkipped} Sample-MKV(s) übersprungen`);
@ -4922,7 +5036,7 @@ export class DownloadManager extends EventEmitter {
let sourceArtifactsChanged = false; let sourceArtifactsChanged = false;
let sourceCleanupRelevant = false; let sourceCleanupRelevant = false;
for (const sourcePath of mkvFiles) { for (const { filePath: sourcePath, sourceRoot } of mkvFiles) {
if (shouldAbort?.()) { if (shouldAbort?.()) {
return; return;
} }
@ -4968,8 +5082,27 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
// Check if identical file already exists in target (same name + same size) → skip instead of creating (2) copy // SAUBEREN Library-Namen ableiten — gleiche Logik wie Auto-Rename (decideAutoRenameBaseName,
const idealTargetPath = path.join(targetDir, path.basename(sourcePath)); // Single Source of Truth). Hat Auto-Rename die Datei NIE erfasst (verpasster Scan oder die
// Datei lag in Downloader Unfertig, ausserhalb der extractDir), traegt sie noch ihren rohen
// Scene-Namen ("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv"). Genau diese landeten bisher
// roh in der Library. Den Namen raeumen wir HIER beim Sammeln auf. null => keine Verbesserung
// moeglich => Roh-Name behalten (raw-keep = Boden, nie schlechter als heute).
const derivedFileName = this.deriveCleanCollectFileName(sourcePath, sourceRoot, pkg);
const desiredFileName = derivedFileName || path.basename(sourcePath);
if (derivedFileName) {
const resolvedDerive = this.inferItemForMediaLog(pkg, sourcePath, path.basename(sourcePath), targetDir);
this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Name beim Sammeln aus Ordnerkontext abgeleitet (Auto-Rename hatte Datei nicht erfasst)", {
sourcePath,
rohName: path.basename(sourcePath),
sauberName: derivedFileName
}, resolvedDerive.item, resolvedDerive.matchedBy);
}
// Check if identical file already exists in target (same name + same size) → skip instead of creating (2) copy.
// WICHTIG: gegen den DERIVED (sauberen) Zielnamen pruefen, nicht den Roh-Namen — sonst findet
// der Dedup eine bereits sauber gesammelte Datei nicht und es entsteht eine "(2)"-Kopie.
const idealTargetPath = path.join(targetDir, desiredFileName);
try { try {
const existingStat = await fs.promises.stat(idealTargetPath); const existingStat = await fs.promises.stat(idealTargetPath);
if (existingStat.size === sourceSize) { if (existingStat.size === sourceSize) {
@ -4995,7 +5128,7 @@ export class DownloadManager extends EventEmitter {
// File doesn't exist in target yet — proceed normally // File doesn't exist in target yet — proceed normally
} }
const targetPath = await this.buildUniqueFlattenTargetPath(targetDir, sourcePath, reservedTargets); const targetPath = await this.buildUniqueFlattenTargetPath(targetDir, sourcePath, reservedTargets, desiredFileName);
if (pathKey(sourcePath) === pathKey(targetPath)) { if (pathKey(sourcePath) === pathKey(targetPath)) {
skipped += 1; skipped += 1;
continue; continue;

View File

@ -157,3 +157,38 @@ falsches ERROR. Zusatz: readdir-Fehler darf nicht zu „Schreibweise ok" degradi
**Meta:** Bei einem Feature, dessen ganzer Zweck Beobachtbarkeit/Verifikation ist, lohnt **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 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". Pfade, case-insensitive FS, EXDEV) korrekt urteilen?" — nicht nur „kompiliert + Happy-Path-Test".
## 2026-06-03 — Renaming „nie 100%": entkoppelte Scans + Namens-Fabrikation aus token-losen Ordnern
**Symptom (aus dem Desktop-Rename-Log diagnostiziert):** 17 Dateien landeten ROH in der
Library ("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv"). KEINE [ERROR]-Zeile — alle [INFO],
weil die Verifikation nur „liegt die Datei am Zielnamen?" prüft, nicht „ist der Zielname
sinnvoll?". Das Logging hat den Bug sichtbar gemacht (genau sein Zweck).
**Root Cause 1 (entkoppelte Scans):** Auto-Rename (scannt nur extractDir, nur present-and-
stable Dateien, Freshness-Gate loggt nur via logger.info → keine Session-Spur) und
collectMkvFilesToLibrary (verschiebt JEDE .mkv, behielt den rohen Basename) sind getrennte
Scans. Eine von Auto-Rename verpasste Datei (verpasster Zyklus ODER lag in „Downloader
Unfertig" außerhalb extractDir) wurde von collect roh weggeschoben. **Fix:** collect leitet
den sauberen Namen SELBST ab — über dieselbe Funktion wie Auto-Rename (decideAutoRenameBaseName,
single source of truth) → Race wird egal, beide Pfade können nicht mehr divergieren.
**Root Cause 2 (latente Fabrikation, vom Advisor gefunden):** decideAutoRenameBaseName
fabrizierte „Mega-Direct-Pack.S01E01" für einen generischen Paketordner, weil
`hasSceneGroupSuffix("Mega-Direct-Pack")` auf „-Pack" falsch-positiv matcht und Guard B dann
die Quell-Episode an einen token-losen Ordner anhängt. Das hätte AUTO-RENAME genauso getroffen
(nur dormant, weil echte Releases saubere Ordner haben). **Fix an der Wurzel:** Rename nur,
wenn IRGENDEIN folderCandidate einen echten Season-/Episode-Token trägt — ein token-loser
Ordner kann keine Episode autoritativ benennen.
**Meta-Lektionen:**
1. Bei „X nie 100%": die Fehler aus dem ECHTEN Log ziehen (greppen), nicht raten. Hier:
„Kein Server" 0×, „Antwort leer" 20k×; und 17 vs vermutete 12 (5 begannen mit Ziffer „4").
2. Symptom-Fix vs Wurzel-Fix: ein collect-seitiger Guard (Quell-Auflösung+Codec) hätte das
Symptom kaschiert + eine Restlücke gelassen; der Wurzel-Fix in der gemeinsamen Funktion
schließt BEIDE Pfade + ermöglicht ehrliches 100%.
3. Wenn ein (Sub-)Agent eine empirische Behauptung aufstellt, die der beobachteten Realität
widerspricht (Review: „liefert no-target" vs Test: „benennt um"), NICHT raten — mit einem
Wegwerf-Diagnose-Test die echte Rückgabe sichtbar machen, DANN entscheiden.
4. „raw-keep ist der Boden" als Guard-Prinzip: ein Rename darf nie einen schlechteren Namen
erzeugen als der Originalname.

View File

@ -8,9 +8,112 @@ import {
buildAutoRenameBaseNameFromFolders, buildAutoRenameBaseNameFromFolders,
buildAutoRenameBaseNameFromFoldersWithOptions, buildAutoRenameBaseNameFromFoldersWithOptions,
hasMeaningfulSeriesPrefix, hasMeaningfulSeriesPrefix,
looksLikeObfuscatedSceneFileName looksLikeObfuscatedSceneFileName,
decideAutoRenameBaseName
} from "../src/main/download-manager"; } from "../src/main/download-manager";
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
// Characterization corpus: pins the EXACT decision for the real failures from
// rename-session_2026-06-02 (17 raw files that mkv-move moved un-renamed) plus
// the guard cases. mkv-collect now routes through this same function so a file
// auto-rename missed still lands clean in the library.
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
const folders = [
"Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV",
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
];
const decision = decideAutoRenameBaseName(
folders,
source,
"tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720",
folders[0],
folders[1]
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV");
});
it("derives the clean name from a SEASON-only folder by injecting the source episode token (Herzflimmern S03E14)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720.mkv";
const seasonFolder = "Herzflimmern.die.Klinik.am.See.S03.German.720p.Webrip.x264-TVARCHiV";
const decision = decideAutoRenameBaseName(
[seasonFolder],
source,
"tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720",
seasonFolder,
seasonFolder
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.die.Klinik.am.See.S03E14.German.720p.Webrip.x264-TVARCHiV");
});
it("derives the clean name for the Fritzie S04 files that sat raw in Downloader Unfertig (4sf- scene group, season folder)", () => {
const source = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
const seasonFolder = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
const decision = decideAutoRenameBaseName(
[seasonFolder],
source,
"4sf-fritzie.himmel.muss.warten.web.7p-s04e01",
seasonFolder,
seasonFolder
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF");
});
it("is idempotent: an already-clean file in its clean folder derives to the same name (no worse-than-now)", () => {
const clean = "Herzflimmern.Die.Klinik.am.See.S07E02.German.720p.Webrip.x264-TVARCHiV";
const decision = decideAutoRenameBaseName(
[clean, "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"],
`${clean}.mkv`,
clean,
clean,
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(clean);
});
it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => {
// Obfuscated file (E16) inside an explicitly-named E01 folder → trust the folder.
const decision = decideAutoRenameBaseName(
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
"awa-diethundermans02e16hd.mkv",
"awa-diethundermans02e16hd",
"Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake",
"Die.Thundermans.S02.GERMAN.x264-aWake"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toContain("S02E01");
});
it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => {
// Clean source S01E09 in a folder that says E08 → must NOT rename to E08.
const decision = decideAutoRenameBaseName(
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f",
"The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON",
"The.Royals.2015.S01.German.DL.720p.BluRay.x264-iNTENTiON"
);
expect(decision.kind).toBe("skip");
expect(decision.kind === "skip" && decision.reason).toBe("token-mismatch");
});
it("skips (no-target) when no folder candidate yields a usable scene name", () => {
const decision = decideAutoRenameBaseName(
["random user folder", "another plain dir"],
"some.file.mkv",
"some.file",
"random user folder",
"another plain dir"
);
expect(decision.kind).toBe("skip");
});
});
describe("hasMeaningfulSeriesPrefix", () => { describe("hasMeaningfulSeriesPrefix", () => {
it("recognizes a real series name before the season token", () => { it("recognizes a real series name before the season token", () => {
expect(hasMeaningfulSeriesPrefix("Desperate.Housewives.S01.Synced.DL.720p.WEB-DL.AC3.h264")).toBe(true); expect(hasMeaningfulSeriesPrefix("Desperate.Housewives.S01.Synced.DL.720p.WEB-DL.AC3.h264")).toBe(true);

View File

@ -9429,6 +9429,138 @@ describe("download manager", () => {
void manager; void manager;
}, 20000); }, 20000);
it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => {
// Echter Bug aus rename-session_2026-06-02: Auto-Rename verpasste einzelne Dateien
// (verpasster Scan / lag ausserhalb der extractDir), der Collect schob sie dann ROH in
// die Library ("tvarchiv...s07e12-720.mkv"). Fix: Collect leitet den sauberen Namen
// selbst ab (gleiche Logik wie Auto-Rename) — die Library-Datei heisst garantiert sauber,
// auch wenn KEIN Auto-Rename-Pass die Datei je angefasst hat (hier: Collect direkt
// aufgerufen, ohne vorherigen Auto-Rename-Lauf).
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV";
const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName);
// Per-Episoden-Ordner (vom Release-Group sauber benannt) mit ROHER MKV darin.
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true });
const rawName = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
const rawPath = path.join(epDir, rawName);
fs.writeFileSync(rawPath, Buffer.alloc(4096, 7));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
autoRename4sf4sj: true, // Umbenennen AN — wie in der echten User-Config
collectMkvToLibrary: true,
mkvLibraryDir,
enableIntegrityCheck: false,
cleanupMode: "none"
},
session,
createStoragePaths(path.join(root, "state"))
);
// Collect DIREKT aufrufen, OHNE vorherigen Auto-Rename-Lauf — simuliert genau die
// verpasste Datei. deferFreshFiles=false (finaler Pass).
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`);
const rawLibPath = path.join(mkvLibraryDir, rawName);
// Library-Datei heisst SAUBER, nicht roh; Quelle ist weg.
expect(fs.existsSync(cleanLibPath)).toBe(true);
expect(fs.existsSync(rawLibPath)).toBe(false);
expect(fs.existsSync(rawPath)).toBe(false);
void manager;
}, 20000);
it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => {
// Die 5 Fritzie-S04-Dateien lagen in "Downloader Unfertig" (= outputDir-Seite, NICHT
// extractDir) — Auto-Rename scannt nur extractDir, sah sie also nie. Collect muss sie
// trotzdem aus dem Staffel-Ordner heraus sauber benennen, und der Untertitel muss
// mit dem Video mitwandern (auf den GEAENDERTEN Namen).
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
const outputDir = path.join(root, "downloads", packageName); // = "Unfertig"-Aequivalent
const extractDir = path.join(root, "extract", packageName); // bleibt leer/fehlt
fs.mkdirSync(outputDir, { recursive: true });
const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
const rawSrt = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.de.srt";
fs.writeFileSync(path.join(outputDir, rawName), Buffer.alloc(4096, 4));
fs.writeFileSync(path.join(outputDir, rawSrt), Buffer.from("1\n00:00:01,000 --> 00:00:02,000\nhi\n"));
const session = emptySession();
const packageId = `${packageName}-pkg`;
const createdAt = Date.now() - 60_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
autoRename4sf4sj: true,
collectMkvToLibrary: true,
mkvLibraryDir,
enableIntegrityCheck: false,
cleanupMode: "none"
},
session,
createStoragePaths(path.join(root, "state"))
);
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF";
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false);
// Untertitel folgt dem Video auf den sauberen Namen (Sprach-Suffix .de erhalten).
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true);
void manager;
}, 20000);
it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => { it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => {
// Folge-Fund zu 18eada9 (verifiziert via Advisor-Gate): 18eada9 schloss den // Folge-Fund zu 18eada9 (verifiziert via Advisor-Gate): 18eada9 schloss den
// "frische Datei landet unbenannt"-Bug nur fuer den HYBRID-Pfad (deferFreshFiles=true // "frische Datei landet unbenannt"-Bug nur fuer den HYBRID-Pfad (deferFreshFiles=true