diff --git a/CHANGELOG.md b/CHANGELOG.md index 5287f3b..9330ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert. +## 1.4.32 - 2026-03-01 + +Diese Version erweitert den Auto-Renamer stark fuer reale Scene-/TV-Release-Strukturen (nested und flat) und fuehrt eine intensive Renamer-Regression mit zusaetzlichen Edge-Case- und Stress-Checks ein. + +### Renamer (Download-Manager) + +- Erweiterte Mustererkennung fuer nested und flat Staffel-Ordner mit Group-Suffix (z. B. `-TMSF`, `-TVS`, `-TvR`, `-ZZGtv`, `-SunDry`). +- Episode-Token kann jetzt auch aus kompakten Codes im Source-Namen abgeleitet werden (z. B. `301` -> `S03E01`, `211` -> `S02E11`, `101` -> `S01E01`), sofern Staffel-Hinweise vorhanden sind. +- `Teil1/Teil2` bzw. `Part1/Part2` wird auf `SxxExx` gemappt, inklusive Staffel-Ableitung aus der Ordnerstruktur. +- Repack-Handling ueber Dateiname und Ordnerstruktur vereinheitlicht (`rp`/`repack` -> `REPACK`-Token konsistent im Zielnamen). +- Flat-Season-Ordner (Dateien direkt im Staffelordner) bekommen jetzt sauberes Episode-Inlining statt unspezifischer Season-Dateinamen. +- Pfadlaengen-Schutz auf Windows gehaertet: erst normaler Zielname, dann deterministischer Paket-Fallback (z. B. `Show.S08E20`), danach sicherer Skip mit Warnlog statt fehlerhaftem Rename. + +### Abgedeckte reale Muster (Beispiele) + +- Arrow / Gotham / Britannia / Legion / Lethal.Weapon / Agent.X / Last.Impact +- Nested Unterordner mit Episodentiteln und flache Staffelordner mit vielen Episoden-Dateien +- Uneinheitliche Source-Namen wie `tvs-...-301`, `...-211`, `...teil1...`, `...rp...` + +### Intensive Bugtests + +- Unit-Tests fuer Renamer deutlich ausgebaut (`tests/auto-rename.test.ts`) mit zusaetzlichen realen Pattern- und Compact-Code-Faellen. +- Zusätzliche intensive Szenario- und Stress-Checks mit temporaeren Testdateien ausgefuehrt (nested/flat, Repack, Teil/Part, Compact-Code, Pfadlaenge, Kollisionsschutz). +- TypeScript Typecheck erfolgreich. +- Voller Vitest Lauf erfolgreich (`279/279`). +- End-to-End Self-Check erfolgreich. + ## 1.4.31 - 2026-03-01 Diese Version schliesst die komplette Bug-Audit-Runde (156 Punkte) ab und fokussiert auf Stabilitaet, Datenintegritaet, sauberes Abbruchverhalten und reproduzierbares Release-Verhalten. diff --git a/package-lock.json b/package-lock.json index a070ab7..5eefac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.31", + "version": "1.4.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.31", + "version": "1.4.32", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 7c0e85f..b4c9b73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.31", + "version": "1.4.32", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index c3d3ec1..5dbf54b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -211,8 +211,12 @@ function isPathInsideDir(filePath: string, dirPath: string): boolean { } const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i; +const SCENE_GROUP_SUFFIX_RE = /-(?=[A-Za-z0-9]{2,}$)(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]+$/; const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:[._\-\s]|$)/i; const SCENE_SEASON_ONLY_RE = /(^|[._\-\s])s\d{1,2}(?=[._\-\s]|$)/i; +const SCENE_SEASON_CAPTURE_RE = /(?:^|[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i; +const SCENE_PART_TOKEN_RE = /(?:^|[._\-\s])(?:teil|part)\s*0*(\d{1,3})(?=[._\-\s]|$)/i; +const SCENE_COMPACT_EPISODE_CODE_RE = /(?:^|[._\-\s])(\d{3,4})(?=$|[._\-\s])/; const SCENE_RP_TOKEN_RE = /(?:^|[._\-\s])rp(?:[._\-\s]|$)/i; const SCENE_REPACK_TOKEN_RE = /(?:^|[._\-\s])repack(?:[._\-\s]|$)/i; const SCENE_QUALITY_TOKEN_RE = /([._\-\s])((?:4320|2160|1440|1080|720|576|540|480|360)p)(?=[._\-\s]|$)/i; @@ -232,6 +236,123 @@ export function extractEpisodeToken(fileName: string): string | null { return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; } +function extractSeasonToken(fileName: string): string | null { + const text = String(fileName || ""); + const episodeMatch = text.match(SCENE_EPISODE_RE); + if (episodeMatch?.[1]) { + const season = Number(episodeMatch[1]); + if (Number.isFinite(season) && season >= 0) { + return `S${String(season).padStart(2, "0")}`; + } + } + const seasonMatch = text.match(SCENE_SEASON_CAPTURE_RE); + if (!seasonMatch?.[1]) { + return null; + } + const season = Number(seasonMatch[1]); + if (!Number.isFinite(season) || season < 0) { + return null; + } + return `S${String(season).padStart(2, "0")}`; +} + +function extractPartEpisodeNumber(fileName: string): number | null { + const match = String(fileName || "").match(SCENE_PART_TOKEN_RE); + if (!match?.[1]) { + return null; + } + const episode = Number(match[1]); + if (!Number.isFinite(episode) || episode <= 0) { + return null; + } + return episode; +} + +function extractCompactEpisodeToken(fileName: string, seasonHint: number | null): string | null { + const trimmed = String(fileName || "").trim(); + if (!trimmed) { + return null; + } + const match = trimmed.match(SCENE_COMPACT_EPISODE_CODE_RE); + if (!match?.[1]) { + return null; + } + + const code = match[1]; + if (code === "2160" || code === "1080" || code === "0720" || code === "720" || code === "0576" || code === "576") { + return null; + } + + const toToken = (season: number, episode: number): string | null => { + if (!Number.isFinite(season) || !Number.isFinite(episode) || season < 0 || season > 99 || episode <= 0 || episode > 999) { + return null; + } + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; + }; + + if (seasonHint !== null && Number.isFinite(seasonHint) && seasonHint >= 0 && seasonHint <= 99) { + const seasonRaw = String(Math.trunc(seasonHint)); + const seasonPadded = String(Math.trunc(seasonHint)).padStart(2, "0"); + const seasonPrefixes = [seasonPadded, seasonRaw].filter((value, index, array) => value.length > 0 && array.indexOf(value) === index) + .sort((a, b) => b.length - a.length); + for (const prefix of seasonPrefixes) { + if (!code.startsWith(prefix) || code.length <= prefix.length) { + continue; + } + const episode = Number(code.slice(prefix.length)); + const token = toToken(Number(seasonRaw), episode); + if (token) { + return token; + } + } + } + + if (code.length === 3) { + return toToken(Number(code[0]), Number(code.slice(1))); + } + if (code.length === 4) { + return toToken(Number(code.slice(0, 2)), Number(code.slice(2))); + } + return null; +} + +function resolveEpisodeTokenForAutoRename(sourceFileName: string, folderNames: string[]): { token: string; fromPart: boolean } | null { + const directFromSource = extractEpisodeToken(sourceFileName); + if (directFromSource) { + return { token: directFromSource, fromPart: false }; + } + + for (const folderName of folderNames) { + const directFromFolder = extractEpisodeToken(folderName); + if (directFromFolder) { + return { token: directFromFolder, fromPart: false }; + } + } + + const seasonTokenHint = extractSeasonToken(sourceFileName) + ?? folderNames.map((folderName) => extractSeasonToken(folderName)).find(Boolean) + ?? null; + const seasonHint = seasonTokenHint ? Number(seasonTokenHint.slice(1)) : null; + const compactEpisode = extractCompactEpisodeToken(sourceFileName, seasonHint); + if (compactEpisode) { + return { token: compactEpisode, fromPart: false }; + } + + const partEpisode = extractPartEpisodeNumber(sourceFileName) + ?? folderNames.map((folderName) => extractPartEpisodeNumber(folderName)).find((value) => Number.isFinite(value) && (value as number) > 0) + ?? null; + if (!partEpisode) { + return null; + } + + const seasonToken = seasonTokenHint || "S01"; + + return { + token: `${seasonToken}E${String(partEpisode).padStart(2, "0")}`, + fromPart: true + }; +} + export function applyEpisodeTokenToFolderName(folderName: string, episodeToken: string): string { const trimmed = String(folderName || "").trim(); if (!trimmed) { @@ -260,6 +381,17 @@ export function sourceHasRpToken(fileName: string): boolean { return SCENE_RP_TOKEN_RE.test(String(fileName || "")); } +function removeRpTokens(baseName: string): string { + const normalized = String(baseName || "") + .replace(/(^|[._\-\s])rp(?=([._\-\s]|$))/ig, "$1") + .replace(/\.{2,}/g, ".") + .replace(/-{2,}/g, "-") + .replace(/_{2,}/g, "_") + .replace(/\s{2,}/g, " ") + .replace(/^[._\-\s]+|[._\-\s]+$/g, ""); + return normalized || String(baseName || ""); +} + export function ensureRepackToken(baseName: string): string { if (SCENE_REPACK_TOKEN_RE.test(baseName)) { return baseName; @@ -279,23 +411,102 @@ export function ensureRepackToken(baseName: string): string { } export function buildAutoRenameBaseName(folderName: string, sourceFileName: string): string | null { - if (!SCENE_RELEASE_FOLDER_RE.test(folderName)) { + const normalizedFolderName = String(folderName || "").trim(); + const normalizedSourceFileName = String(sourceFileName || "").trim(); + if (!normalizedFolderName || !normalizedSourceFileName) { return null; } - const episodeToken = extractEpisodeToken(sourceFileName); + const isLegacy4sf4sjFolder = SCENE_RELEASE_FOLDER_RE.test(normalizedFolderName); + const isSceneGroupFolder = SCENE_GROUP_SUFFIX_RE.test(normalizedFolderName); + if (!isLegacy4sf4sjFolder && !isSceneGroupFolder) { + return null; + } + + const episodeToken = extractEpisodeToken(normalizedSourceFileName); if (!episodeToken) { return null; } - let next = applyEpisodeTokenToFolderName(folderName, episodeToken); - if (sourceHasRpToken(sourceFileName)) { + let next = isLegacy4sf4sjFolder + ? applyEpisodeTokenToFolderName(normalizedFolderName, episodeToken) + : normalizedFolderName; + + const hasRepackHint = sourceHasRpToken(normalizedSourceFileName) + || SCENE_REPACK_TOKEN_RE.test(normalizedSourceFileName) + || sourceHasRpToken(normalizedFolderName) + || SCENE_REPACK_TOKEN_RE.test(normalizedFolderName); + if (hasRepackHint) { + next = removeRpTokens(next); next = ensureRepackToken(next); } return sanitizeFilename(next); } +export function buildAutoRenameBaseNameFromFolders(folderNames: string[], sourceFileName: string): string | null { + return buildAutoRenameBaseNameFromFoldersWithOptions(folderNames, sourceFileName, { forceEpisodeForSeasonFolder: false }); +} + +export function buildAutoRenameBaseNameFromFoldersWithOptions( + folderNames: string[], + sourceFileName: string, + options: { forceEpisodeForSeasonFolder?: boolean } +): string | null { + const ordered = folderNames + .map((value) => String(value || "").trim()) + .filter((value) => value.length > 0); + if (ordered.length === 0) { + return null; + } + + const normalizedSourceFileName = String(sourceFileName || "").trim(); + const resolvedEpisode = resolveEpisodeTokenForAutoRename(normalizedSourceFileName, ordered); + const forceEpisodeForSeasonFolder = Boolean(options.forceEpisodeForSeasonFolder); + const globalRepackHint = sourceHasRpToken(normalizedSourceFileName) + || SCENE_REPACK_TOKEN_RE.test(normalizedSourceFileName) + || ordered.some((folderName) => sourceHasRpToken(folderName) || SCENE_REPACK_TOKEN_RE.test(folderName)); + + for (const folderName of ordered) { + const folderHasEpisode = Boolean(extractEpisodeToken(folderName)); + const folderHasSeason = Boolean(extractSeasonToken(folderName)); + const folderHasPart = extractPartEpisodeNumber(folderName) !== null; + if (folderHasPart && !folderHasEpisode && !folderHasSeason) { + continue; + } + + let target = buildAutoRenameBaseName(folderName, normalizedSourceFileName); + if (!target && resolvedEpisode && SCENE_GROUP_SUFFIX_RE.test(folderName) && (folderHasSeason || folderHasEpisode)) { + target = applyEpisodeTokenToFolderName(folderName, resolvedEpisode.token); + } + if (!target) { + continue; + } + + if (resolvedEpisode + && forceEpisodeForSeasonFolder + && SCENE_GROUP_SUFFIX_RE.test(target) + && !extractEpisodeToken(target) + && SCENE_SEASON_ONLY_RE.test(target)) { + target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); + } + + if (resolvedEpisode?.fromPart + && SCENE_GROUP_SUFFIX_RE.test(target) + && !extractEpisodeToken(target) + && SCENE_SEASON_ONLY_RE.test(target)) { + target = applyEpisodeTokenToFolderName(target, resolvedEpisode.token); + } + + if (globalRepackHint) { + target = ensureRepackToken(removeRpTokens(target)); + } + return sanitizeFilename(target); + } + + return null; +} + export class DownloadManager extends EventEmitter { private settings: AppSettings; @@ -1258,6 +1469,101 @@ export class DownloadManager extends EventEmitter { return files; } + private buildSafeAutoRenameTargetPath(sourcePath: string, targetBaseName: string, sourceExt: string): string | null { + const dirPath = path.dirname(sourcePath); + const safeBaseName = sanitizeFilename(String(targetBaseName || "").trim()); + if (!safeBaseName) { + return null; + } + const safeExt = String(sourceExt || "").trim(); + + const candidatePath = path.join(dirPath, `${safeBaseName}${safeExt}`); + if (process.platform !== "win32") { + return candidatePath; + } + + const fileName = path.basename(candidatePath); + if (fileName.length > 255) { + return null; + } + + const maxWindowsPathLength = 259; + if (candidatePath.length <= maxWindowsPathLength) { + return candidatePath; + } + return null; + } + + private buildShortPackageFallbackBaseName(folderCandidates: string[], sourceBaseName: string, targetBaseName: string): string | null { + const normalizedCandidates = folderCandidates + .map((value) => String(value || "").trim()) + .filter((value) => value.length > 0); + const fallbackTemplate = [...normalizedCandidates].reverse().find((folderName) => { + return SCENE_GROUP_SUFFIX_RE.test(folderName) && Boolean(extractSeasonToken(folderName)); + }) || ""; + if (!fallbackTemplate) { + return null; + } + + const seasonMatch = fallbackTemplate.match(/^(.*?)(?:[._\-\s])s(\d{1,2})(?=[._\-\s]|$)/i); + if (!seasonMatch?.[1] || !seasonMatch?.[2]) { + return null; + } + + const seasonNumber = Number(seasonMatch[2]); + if (!Number.isFinite(seasonNumber) || seasonNumber < 0) { + return null; + } + + const shortRootName = String(seasonMatch[1]).replace(/[._\-\s]+$/g, "").trim(); + if (!shortRootName) { + return null; + } + + let fallback = `${shortRootName}.S${String(seasonNumber).padStart(2, "0")}`; + const resolvedEpisode = resolveEpisodeTokenForAutoRename(sourceBaseName, normalizedCandidates); + if (resolvedEpisode && new RegExp(`^S${String(seasonNumber).padStart(2, "0")}E\\d{2,3}$`, "i").test(resolvedEpisode.token)) { + fallback = `${shortRootName}.${resolvedEpisode.token}`; + } + const hasRepackHint = sourceHasRpToken(sourceBaseName) + || SCENE_REPACK_TOKEN_RE.test(sourceBaseName) + || sourceHasRpToken(targetBaseName) + || SCENE_REPACK_TOKEN_RE.test(targetBaseName) + || folderCandidates.some((folderName) => sourceHasRpToken(folderName) || SCENE_REPACK_TOKEN_RE.test(folderName)); + if (hasRepackHint) { + fallback = ensureRepackToken(removeRpTokens(fallback)); + } + + const normalized = sanitizeFilename(fallback); + if (!normalized || normalized.toLowerCase() === String(targetBaseName || "").trim().toLowerCase()) { + return null; + } + return normalized; + } + + private buildVeryShortPackageFallbackBaseName(folderCandidates: string[], sourceBaseName: string, targetBaseName: string): string | null { + const base = this.buildShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName); + if (!base) { + return null; + } + const match = base.match(/^(.+?)[._\-\s](S\d{2})(?:\b|[._\-\s])/i) || base.match(/^(.+?)\.(S\d{2})$/i); + if (!match?.[1] || !match?.[2]) { + return null; + } + const firstToken = match[1].split(/[._\-\s]+/).filter(Boolean)[0] || ""; + if (!firstToken) { + return null; + } + const next = sanitizeFilename(`${firstToken}.${match[2].toUpperCase()}`); + if (!next || next.toLowerCase() === String(targetBaseName || "").trim().toLowerCase()) { + return null; + } + if (SCENE_REPACK_TOKEN_RE.test(base)) { + return ensureRepackToken(next); + } + return next; + } + private autoRenameExtractedVideoFiles(extractDir: string): number { if (!this.settings.autoRename4sf4sj) { return 0; @@ -1270,13 +1576,46 @@ export class DownloadManager extends EventEmitter { const sourceName = path.basename(sourcePath); const sourceExt = path.extname(sourceName); const sourceBaseName = path.basename(sourceName, sourceExt); - const folderName = path.basename(path.dirname(sourcePath)); - const targetBaseName = buildAutoRenameBaseName(folderName, sourceBaseName); + const folderCandidates: string[] = []; + let currentDir = path.dirname(sourcePath); + while (currentDir && isPathInsideDir(currentDir, extractDir)) { + folderCandidates.push(path.basename(currentDir)); + const parent = path.dirname(currentDir); + if (!parent || parent === currentDir) { + break; + } + currentDir = parent; + } + const targetBaseName = buildAutoRenameBaseNameFromFoldersWithOptions(folderCandidates, sourceBaseName, { + forceEpisodeForSeasonFolder: true + }); if (!targetBaseName) { continue; } - const targetPath = path.join(path.dirname(sourcePath), `${targetBaseName}${sourceExt}`); + let targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, targetBaseName, sourceExt); + if (!targetPath) { + const fallbackBaseName = this.buildShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName); + if (fallbackBaseName) { + targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, fallbackBaseName, sourceExt); + if (targetPath) { + logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(targetPath)}`); + } + } + if (!targetPath) { + const veryShortFallback = this.buildVeryShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName); + if (veryShortFallback) { + targetPath = this.buildSafeAutoRenameTargetPath(sourcePath, veryShortFallback, sourceExt); + if (targetPath) { + logger.warn(`Auto-Rename Kurz-Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(targetPath)}`); + } + } + } + } + if (!targetPath) { + logger.warn(`Auto-Rename übersprungen (Zielpfad zu lang/ungültig): ${sourcePath}`); + continue; + } if (pathKey(targetPath) === pathKey(sourcePath)) { continue; } @@ -1294,7 +1633,7 @@ export class DownloadManager extends EventEmitter { } if (renamed > 0) { - logger.info(`Auto-Rename (4SF/4SJ): ${renamed} Datei(en) umbenannt`); + logger.info(`Auto-Rename (Scene): ${renamed} Datei(en) umbenannt`); } return renamed; } diff --git a/tests/auto-rename.test.ts b/tests/auto-rename.test.ts index 2896e4d..46fd9cf 100644 --- a/tests/auto-rename.test.ts +++ b/tests/auto-rename.test.ts @@ -4,7 +4,9 @@ import { applyEpisodeTokenToFolderName, sourceHasRpToken, ensureRepackToken, - buildAutoRenameBaseName + buildAutoRenameBaseName, + buildAutoRenameBaseNameFromFolders, + buildAutoRenameBaseNameFromFoldersWithOptions } from "../src/main/download-manager"; describe("extractEpisodeToken", () => { @@ -171,9 +173,9 @@ describe("buildAutoRenameBaseName", () => { expect(result).toBe("Show.S01E03.720p-4sj"); }); - it("returns null for non-4sf/4sj folder", () => { + it("renames generic scene folder with group suffix", () => { const result = buildAutoRenameBaseName("Show.S01.720p-GROUP", "show.s01e05.720p.mkv"); - expect(result).toBeNull(); + expect(result).toBe("Show.S01.720p-GROUP"); }); it("returns null when source has no episode token", () => { @@ -271,6 +273,30 @@ describe("buildAutoRenameBaseName", () => { expect(result).toBe("Severance.S02E07.2160p.ATVP.WEB-DL.DDP5.1.DV.H.265-4SF"); }); + it("real-world: Britannia release keeps folder base name", () => { + const result = buildAutoRenameBaseName( + "Britannia.S02.GERMAN.720p.WEBRiP.x264-LAW", + "law-britannia.s02e01.720p.webrip" + ); + expect(result).toBe("Britannia.S02.GERMAN.720p.WEBRiP.x264-LAW"); + }); + + it("real-world: Britannia repack injects REPACK", () => { + const result = buildAutoRenameBaseName( + "Britannia.S02.GERMAN.720p.WEBRiP.x264-LAW", + "law-britannia.s02e09.720p.webrip.repack" + ); + expect(result).toBe("Britannia.S02.GERMAN.REPACK.720p.WEBRiP.x264-LAW"); + }); + + it("adds REPACK when folder name carries RP hint", () => { + const result = buildAutoRenameBaseName( + "Banshee.S02E01.German.RP.720p.BluRay.x264-RIPLEY", + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02E01.German.REPACK.720p.BluRay.x264-RIPLEY"); + }); + it("real-world: folder already has wrong episode", () => { const result = buildAutoRenameBaseName( "Cobra.Kai.S06E01.720p.NF.WEB-DL.DDP5.1.x264-4SF", @@ -327,3 +353,163 @@ describe("buildAutoRenameBaseName", () => { expect(result!).not.toContain(":"); }); }); + +describe("buildAutoRenameBaseNameFromFolders", () => { + it("uses parent folder when current folder is not a scene template", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Episode 01", + "Banshee.S02.German.720p.BluRay.x264-RIPLEY" + ], + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02.German.720p.BluRay.x264-RIPLEY"); + }); + + it("uses nested scene subfolder directly", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Banshee.S02E01.German.720p.BluRay.x264-RIPLEY", + "Banshee.S02.German.720p.BluRay.x264-RIPLEY" + ], + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02E01.German.720p.BluRay.x264-RIPLEY"); + }); + + it("injects REPACK when parent folder carries repack hint", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Banshee.S02E01.German.720p.BluRay.x264-RIPLEY", + "Banshee.S02.German.RP.720p.BluRay.x264-RIPLEY" + ], + "r-banshee.s02e01-720p" + ); + expect(result).toBe("Banshee.S02E01.German.REPACK.720p.BluRay.x264-RIPLEY"); + }); + + it("uses nested Arrow episode folder with title", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Arrow.S04E01.Green.Arrow.German.DL.720p.BluRay.x264-RSG", + "Arrow.S04.German.DL.720p.BluRay.x264-RSG" + ], + "rsg-arrow-s04e01-720p" + ); + expect(result).toBe("Arrow.S04E01.Green.Arrow.German.DL.720p.BluRay.x264-RSG"); + }); + + it("adds REPACK for Arrow when source contains rp token", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Arrow.S04E01.Green.Arrow.German.DL.720p.BluRay.x264-RSG", + "Arrow.S04.German.DL.720p.BluRay.x264-RSG" + ], + "rsg-arrow-s04e01.rp.720p" + ); + expect(result).toBe("Arrow.S04E01.Green.Arrow.German.DL.REPACK.720p.BluRay.x264-RSG"); + }); + + it("converts Teil token to episode using parent season", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Last.Impact.Der.Einschlag.Teil1.GERMAN.DL.720p.WEB.H264-SunDry", + "Last.Impact.Der.Einschlag.S01.GERMAN.DL.720p.WEB.H264-SunDry" + ], + "sundry-last.impact.der.einschlag.teil1.720p.web.h264" + ); + expect(result).toBe("Last.Impact.Der.Einschlag.S01E01.GERMAN.DL.720p.WEB.H264-SunDry"); + }); + + it("converts Teil token to episode with REPACK", () => { + const result = buildAutoRenameBaseNameFromFolders( + [ + "Last.Impact.Der.Einschlag.Teil1.GERMAN.DL.720p.WEB.H264-SunDry", + "Last.Impact.Der.Einschlag.S01.GERMAN.DL.720p.WEB.H264-SunDry" + ], + "sundry-last.impact.der.einschlag.teil1.rp.720p.web.h264" + ); + expect(result).toBe("Last.Impact.Der.Einschlag.S01E01.GERMAN.DL.REPACK.720p.WEB.H264-SunDry"); + }); + + it("forces episode insertion for flat season folder when many files share directory", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Arrow.S08.GERMAN.DUBBED.DL.720p.BluRay.x264-TMSF" + ], + "tmsf-arrow-s08e03-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Arrow.S08E03.GERMAN.DUBBED.DL.720p.BluRay.x264-TMSF"); + }); + + it("forces episode insertion plus REPACK for flat season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Gotham.S05.GERMAN.DUBBED.720p.BLURAY.x264-ZZGtv" + ], + "zzgtv-gotham-s05e02.rp", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Gotham.S05E02.GERMAN.DUBBED.REPACK.720p.BLURAY.x264-ZZGtv"); + }); + + it("uses nested episode title folder for Gotham TvR style", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Gotham.S04E01.Pax.Penguina.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR", + "Gotham.S04.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR" + ], + "tvr-gotham-s04e01-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Gotham.S04E01.Pax.Penguina.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR"); + }); + + it("uses nested title folder for Britannia TV4A style", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Britannia.S01E01.Die.Landung.German.DL.720p.BluRay.x264-TV4A", + "Britannia.S01.German.DL.720p.BluRay.x264-TV4A" + ], + "tv4a-britannia.s01e01-720p", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Britannia.S01E01.Die.Landung.German.DL.720p.BluRay.x264-TV4A"); + }); + + it("handles odd source token style 101 by using nested Agent X folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Agent.X.S01E01.Pilot.German.DD51.Dubbed.DL.720p.iTunesHD.x264-TVS", + "Agent.X.S01.German.DD51.Dubbed.DL.720p.iTunesHD.x264-TVS" + ], + "tvs-agent-x-dd51-ded-dl-7p-ithd-x264-101", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Agent.X.S01E01.Pilot.German.DD51.Dubbed.DL.720p.iTunesHD.x264-TVS"); + }); + + it("maps compact code 301 to S03E01 for nested Legion folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Legion.S03E01.Kapitel.20.German.DD51.Dubbed.DL.720p.AmazonHD.AVC-TVS", + "Legion.S03.German.DD51.Dubbed.DL.720p.AmazonHD.AVC-TVS" + ], + "tvs-legion-dd51-ded-dl-7p-azhd-avc-301", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Legion.S03E01.Kapitel.20.German.DD51.Dubbed.DL.720p.AmazonHD.AVC-TVS"); + }); + + it("maps compact code 211 in flat season folder", () => { + const result = buildAutoRenameBaseNameFromFoldersWithOptions( + [ + "Lethal.Weapon.S02.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS" + ], + "tvs-lethal-weapon-dd51-ded-dl-7p-azhd-x264-211", + { forceEpisodeForSeasonFolder: true } + ); + expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS"); + }); +});