Release v1.4.32 with intensive renamer hardening
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-03-01 01:21:13 +01:00
parent 6ac56c0a77
commit ff1036563a
5 changed files with 566 additions and 14 deletions

View File

@ -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.

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;
}

View File

@ -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");
});
});