diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index dd7f15d..e0b72b2 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1407,6 +1407,107 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( 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. const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; const ARCHIVE_RAR_RE = /^(.*)\.rar$/; @@ -4183,141 +4284,85 @@ export class DownloadManager extends EventEmitter { folderCandidates.push(extra); } } - let targetBaseName = buildAutoRenameBaseNameFromFoldersWithOptions(folderCandidates, sourceBaseName, { - forceEpisodeForSeasonFolder: true - }); - // Defense against degenerate folder layouts: when the immediate parent - // folder lacks a real series name (e.g. "S01 Complete", "Season 1", - // "Staffel 02"), buildAutoRenameBaseName can collapse a perfect source - // filename like "Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB- - // DL.AC3.h264" into garbage like "S01E01 Complete". If the source is - // already well-formed (has SxxExx + a meaningful series-name prefix) - // and the computed target is much shorter / lacks that prefix, keep - // the source as-is — renaming would actively destroy information. - 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) { - 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; - } - } + // Naming-Entscheidung ueber die GEMEINSAME Funktion (decideAutoRenameBaseName) — + // 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 + // Collect hatte keine → von Auto-Rename verpasste Dateien landeten roh in der Library. + const decision = decideAutoRenameBaseName( + folderCandidates, + sourceName, + sourceBaseName, + path.basename(path.dirname(sourcePath)), + path.basename(extractDir) + ); + // Skip-Branches behalten den BERECHNETEN Zielnamen fuer die Log-Attribution + // (resolveRenameItem speist ihn in inferItemForMediaLog) — wie im Original; no-target + // bleibt null, damit der `if (!targetBaseName)`-Zweig weiter greift. + let targetBaseName: string | null = decision.kind === "rename" ? decision.baseName : (decision.targetBaseName ?? null); 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); }; - // SAFETY NET: Never strip a valid SxxExx token from the source filename. - // If the source already has an episode token but the computed target lost it - // (e.g. malformed package name "S01GERMAN" with no separator), preserve the - // episode by either inserting it into the target or skipping the rename entirely. - // Without this guard, all episodes from a malformed pack collapse to one name - // and collide with (2)(3)(4) suffixes in the MKV library. - const sourceEpisodeToken = extractEpisodeToken(sourceBaseName); - if (targetBaseName && sourceEpisodeToken) { - const targetEpisodeToken = extractEpisodeToken(targetBaseName); - if (!targetEpisodeToken) { - // Try to insert the source's episode token: replace "Sxx" with "SxxExx." - const insertedEpisode = targetBaseName.replace( - /(^|[._\-\s])(s\d{1,2})(?=[A-Za-z0-9])/i, - `$1${sourceEpisodeToken}.` - ); - if (insertedEpisode !== targetBaseName && extractEpisodeToken(insertedEpisode)) { - logger.info(`Auto-Rename Safety: Episode-Token in Target eingefuegt: ${targetBaseName} -> ${insertedEpisode}`); - targetBaseName = insertedEpisode; - } else { - const repaired = applyEpisodeTokenToFolderName(targetBaseName, sourceEpisodeToken); - if (repaired && extractEpisodeToken(repaired)) { - logger.info(`Auto-Rename Safety: Episode-Token via applyToken: ${targetBaseName} -> ${repaired}`); - targetBaseName = repaired; - } else { - logger.warn(`Auto-Rename Safety: Skipping rename - target wuerde Episode-Token verlieren (source=${sourceBaseName}, target=${targetBaseName})`); - if (pkg) { - const resolved = resolveRenameItem(); - this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token wuerde verloren gehen", { - sourcePath, - sourceName, - sourceEpisodeToken, - targetBaseName - }, resolved.item, resolved.matchedBy); - } - continue; - } - } - } else if (targetEpisodeToken !== sourceEpisodeToken) { - // Target has a DIFFERENT episode token than source. Normally that's a - // sign the rename would mislabel the episode — BUT scene releases - // often place obfuscated MKVs (e.g. "awa-diethundermans02e16hd.mkv" - // = scrambled E16) inside an explicitly named episode folder - // (e.g. "Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"). - // The folder is created by the release group with the REAL episode - // info; the file name is anti-piracy obfuscation. So when the - // immediate parent folder carries the same explicit SxxExx token as - // our computed targetBaseName, trust the folder and override the - // misleading source token. - const parentFolderName = path.basename(path.dirname(sourcePath)); - const parentEpisodeToken = extractEpisodeToken(parentFolderName); - // GUARD: only let the folder override the source token when the - // source filename actually LOOKS obfuscated (no scene markers like - // 720p / german / x264 / bluray, no dot-separated structure). - // A clean scene release filename — e.g. "the.royals.2015.s01e09. - // german.dl.720p.bluray.x264-j4f.mkv" — must NEVER be overridden, - // because a one-off folder/file mismatch with a clean source means - // the FOLDER is wrong, not the file. Renaming a real S01E09 to - // S01E08 because the folder happens to say E08 would corrupt data. - 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 (decision.kind === "skip" && decision.reason === "source-better") { + logger.info(`Auto-Rename uebersprungen: Source "${sourceBaseName}" ist bereits aussagekraeftiger als computed Target "${decision.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: decision.targetBaseName, + folders: folderCandidates.join(", ") + }, resolved.item, resolved.matchedBy); + } + continue; + } + if (decision.kind === "skip" && decision.reason === "token-loss") { + logger.warn(`Auto-Rename Safety: Skipping rename - target wuerde Episode-Token verlieren (source=${sourceBaseName}, target=${decision.targetBaseName})`); + if (pkg) { + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token wuerde verloren gehen", { + sourcePath, + sourceName, + sourceEpisodeToken: decision.sourceEpisodeToken, + targetBaseName: decision.targetBaseName + }, resolved.item, resolved.matchedBy); + } + continue; + } + if (decision.kind === "skip" && decision.reason === "token-mismatch") { + logger.warn(`Auto-Rename Safety: Skipping rename - Episode-Token Mismatch (source=${decision.sourceEpisodeToken}, target=${decision.targetEpisodeToken})`); + if (pkg) { + const resolved = resolveRenameItem(); + this.logRenameProcess(pkg, "WARN", "auto-rename", "Auto-Rename uebersprungen: Episode-Token Mismatch", { + sourcePath, + sourceName, + sourceEpisodeToken: decision.sourceEpisodeToken, + targetEpisodeToken: decision.targetEpisodeToken, + targetBaseName: decision.targetBaseName + }, resolved.item, resolved.matchedBy); + } + continue; + } + if (decision.kind === "rename" && decision.note === "token-inserted") { + logger.info(`Auto-Rename Safety: Episode-Token in Target eingefuegt -> ${decision.baseName}`); + } else if (decision.kind === "rename" && decision.note === "token-applyToken") { + logger.info(`Auto-Rename Safety: Episode-Token via applyToken -> ${decision.baseName}`); + } else if (decision.kind === "rename" && decision.note === "folder-override") { + const parentFolderName = path.basename(path.dirname(sourcePath)); + logger.info(`Auto-Rename: source-Token ${decision.sourceEpisodeToken} ignoriert, Folder-Token ${decision.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: decision.sourceEpisodeToken, + targetEpisodeToken: decision.targetEpisodeToken, + parentFolder: parentFolderName, + targetBaseName: decision.baseName + }, resolved.item, resolved.matchedBy); } } if (!targetBaseName) { @@ -4415,7 +4460,7 @@ export class DownloadManager extends EventEmitter { // UNC handling AND the transient-error retry for free. try { await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "auto-rename (Schreibweise)" }); - renamedCount += 1; + renamed += 1; if (pkg) { const resolved = resolveRenameItem(targetPath); this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename (Casing korrigiert)", { @@ -4737,8 +4782,77 @@ export class DownloadManager extends EventEmitter { return false; } - private async buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set): Promise { + /** 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 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, desiredFileName?: string): Promise { + // 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 baseName = sanitizeFilename(parsed.name || "video"); @@ -4878,7 +4992,7 @@ export class DownloadManager extends EventEmitter { // - Bonus-Dateinamen (Making-Of, Deleted-Scene, etc.) const sampleDirNames = new Set(["sample", "samples"]); const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i; - const mkvFiles: string[] = []; + const mkvFiles: { filePath: string; sourceRoot: string }[] = []; let sampleSkipped = 0; let bonusSkipped = 0; 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)})`); continue; } - mkvFiles.push(filePath); + mkvFiles.push({ filePath, sourceRoot }); } if (sampleSkipped > 0) { 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 sourceCleanupRelevant = false; - for (const sourcePath of mkvFiles) { + for (const { filePath: sourcePath, sourceRoot } of mkvFiles) { if (shouldAbort?.()) { return; } @@ -4968,8 +5082,27 @@ export class DownloadManager extends EventEmitter { continue; } - // Check if identical file already exists in target (same name + same size) → skip instead of creating (2) copy - const idealTargetPath = path.join(targetDir, path.basename(sourcePath)); + // SAUBEREN Library-Namen ableiten — gleiche Logik wie Auto-Rename (decideAutoRenameBaseName, + // 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 { const existingStat = await fs.promises.stat(idealTargetPath); if (existingStat.size === sourceSize) { @@ -4995,7 +5128,7 @@ export class DownloadManager extends EventEmitter { // 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)) { skipped += 1; continue; diff --git a/tasks/lessons.md b/tasks/lessons.md index 9bcabfe..4785727 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -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 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". + +## 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. diff --git a/tests/auto-rename.test.ts b/tests/auto-rename.test.ts index 7200fd7..fb14d69 100644 --- a/tests/auto-rename.test.ts +++ b/tests/auto-rename.test.ts @@ -8,9 +8,112 @@ import { buildAutoRenameBaseNameFromFolders, buildAutoRenameBaseNameFromFoldersWithOptions, hasMeaningfulSeriesPrefix, - looksLikeObfuscatedSceneFileName + looksLikeObfuscatedSceneFileName, + decideAutoRenameBaseName } 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", () => { it("recognizes a real series name before the season token", () => { expect(hasMeaningfulSeriesPrefix("Desperate.Housewives.S01.Synced.DL.720p.WEB-DL.AC3.h264")).toBe(true); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 709a398..e44bc09 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -9429,6 +9429,138 @@ describe("download manager", () => { void manager; }, 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 () => { // Folge-Fund zu 18eada9 (verifiziert via Advisor-Gate): 18eada9 schloss den // "frische Datei landet unbenannt"-Bug nur fuer den HYBRID-Pfad (deferFreshFiles=true