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:
parent
9a71e01417
commit
288a0762a6
@ -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;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user