diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 7959b4b..f45e413 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -851,6 +851,21 @@ const SCENE_EPISODE_JOINED_RE = /s(\d{1,2})e(\d{1,3})(?:e(\d{1,3}))?(?!\d)/i; // Scene typo: "S05S01" instead of "S05E01" — second S should be E const SCENE_EPISODE_TYPO_SS_RE = /(?:^|[._\-\s])s(\d{1,2})s(\d{1,3})(?!\d)/i; const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i; + +/** True iff the name has at least 3 alphabetic characters BEFORE the first + * SxxExx / Sxx token. Used to distinguish folders/files with a real series + * name ("Desperate.Housewives.S01...") from generic season labels + * ("S01 Complete", "Season 1"). */ +export function hasMeaningfulSeriesPrefix(name: string): boolean { + const text = String(name || ""); + const seasonMatch = text.match(/(?:^|[._\-\s])s\d{1,2}/i); + if (!seasonMatch || seasonMatch.index === undefined) { + return false; + } + const prefix = text.slice(0, seasonMatch.index); + const alphaChars = (prefix.match(/[A-Za-z]/g) || []).length; + return alphaChars >= 3; +} const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i; const SCENE_EPISODE_ONLY_RE = /(?:^|[._\-\s])e(?:p(?:isode)?)?\s*0*(\d{1,3})(?:[._\-\s]|$)/i; const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i; @@ -3683,6 +3698,39 @@ export class DownloadManager extends EventEmitter { 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; + } + } const resolveRenameItem = (...extra: Array): { item: DownloadItem | null; matchedBy: string | null } => { if (!pkg) { return { item: null, matchedBy: null }; @@ -3727,20 +3775,51 @@ export class DownloadManager extends EventEmitter { } } } else if (targetEpisodeToken !== sourceEpisodeToken) { - // Target has a DIFFERENT episode token than source — that's a clear sign - // the rename is wrong (would mislabel the episode). Skip to be safe. - 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); + // 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); + const folderIsAuthoritative = Boolean( + parentEpisodeToken + && parentEpisodeToken === targetEpisodeToken + && parentFolderName.toLowerCase() !== path.basename(extractDir).toLowerCase() + ); + 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; } - continue; } } if (!targetBaseName) { diff --git a/tests/auto-rename.test.ts b/tests/auto-rename.test.ts index 1a4087b..f076602 100644 --- a/tests/auto-rename.test.ts +++ b/tests/auto-rename.test.ts @@ -6,9 +6,31 @@ import { ensureRepackToken, buildAutoRenameBaseName, buildAutoRenameBaseNameFromFolders, - buildAutoRenameBaseNameFromFoldersWithOptions + buildAutoRenameBaseNameFromFoldersWithOptions, + hasMeaningfulSeriesPrefix } from "../src/main/download-manager"; +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); + expect(hasMeaningfulSeriesPrefix("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake")).toBe(true); + expect(hasMeaningfulSeriesPrefix("Mistresses.2013.S02.GERMAN.DL.720p.WEB.x264-TSCC")).toBe(true); + expect(hasMeaningfulSeriesPrefix("show.name.s01e01.720p")).toBe(true); + }); + + it("rejects generic season-label folders without a series name", () => { + expect(hasMeaningfulSeriesPrefix("S01 Complete")).toBe(false); + expect(hasMeaningfulSeriesPrefix("S02")).toBe(false); + expect(hasMeaningfulSeriesPrefix("S01E01 Complete")).toBe(false); + expect(hasMeaningfulSeriesPrefix(".S01.bla")).toBe(false); + }); + + it("returns false when there is no season token at all", () => { + expect(hasMeaningfulSeriesPrefix("Some Random Folder")).toBe(false); + expect(hasMeaningfulSeriesPrefix("")).toBe(false); + }); +}); + describe("extractEpisodeToken", () => { it("extracts S01E01 from standard scene format", () => { expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01");