Release v1.4.32 with intensive renamer hardening
This commit is contained in:
parent
6ac56c0a77
commit
013f790866
27
CHANGELOG.md
27
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.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user