Bonus dir detection: normalize separators (Making.Of, Behind.The.Scenes)

The v1.7.130 BONUS_DIR_PATTERNS used substring matching with space-separated
patterns like "making of" and "behind the scenes", but real-world subfolder
names use dot/dash/underscore separators (e.g. "Breaking.Bad.S05.Making.Of").
These were NOT detected as bonus dirs, causing the safety net in v1.7.131 to
apply the source filename's episode token to the package name, producing
mislabeled bonus files like "Breaking.Bad.S05E10.GERMAN.BluRay.720p.TSCC".

Fix: normalize folder segments by stripping all separators ([._-\s]+) before
matching against BONUS_DIR_NORMALIZED_PATTERNS. "Breaking.Bad.S05.Making.Of"
normalizes to "breakingbads05makingof" which matches "makingof".

Also extend BONUS_FILENAME_RE with "inside-e\d+" and "making-of-e\d+" to
catch more filename variants from Breaking Bad BluRay extras.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-04-14 15:03:02 +02:00
parent 9f59b6e7ca
commit 6e936cd5bc
2 changed files with 111 additions and 9 deletions

View File

@ -135,15 +135,24 @@ const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:
const KNOWN_SMALL_FILE_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|txt|url|lnk|srr)$/i; const KNOWN_SMALL_FILE_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|txt|url|lnk|srr)$/i;
/** Folder name patterns indicating bonus/extras content that should NOT be moved /** Folder name patterns indicating bonus/extras content that should NOT be moved
* to the flat MKV library or auto-renamed. Matches as a substring of the folder name. */ * to the flat MKV library or auto-renamed. Matches after normalizing separators
const BONUS_DIR_PATTERNS = [ * (so "Making.Of", "making-of", "making of", "makingof" all match "makingof"). */
"extras", "extra", "bonus", "featurettes", "featurette", "specials", "special features", const BONUS_DIR_NORMALIZED_PATTERNS = [
"behind the scenes", "behindthescenes", "deleted scenes", "deletedscenes", "extras", "extra", "bonus", "featurettes", "featurette",
"making of", "makingof", "outtakes", "trailers", "interviews", "documentaries" "specials", "specialfeatures",
"behindthescenes", "deletedscenes", "deletedscene",
"makingof", "outtakes", "trailers", "interviews", "documentaries",
"alternateending", "gagreel"
]; ];
/** Filename token patterns for bonus content (e.g. "making-of-e02.mkv"). */ /** Filename token patterns for bonus content (e.g. "making-of-e02.mkv"). */
const BONUS_FILENAME_RE = /(?:^|[._\-\s])(?:making[._\-\s]?of|behind[._\-\s]?the[._\-\s]?scenes|deleted[._\-\s]?scene|alternate[._\-\s]?ending|gag[._\-\s]?reel|featurette|outtakes?|bloopers?|interview|extended[._\-\s]?scene|exclusive[._\-\s]?scene)(?:[._\-\s]|$)/i; const BONUS_FILENAME_RE = /(?:^|[._\-\s])(?:making[._\-\s]?of|behind[._\-\s]?the[._\-\s]?scenes|deleted[._\-\s]?scene|alternate[._\-\s]?ending|gag[._\-\s]?reel|featurette|outtakes?|bloopers?|interview|extended[._\-\s]?scene|exclusive[._\-\s]?scene|inside[._\-\s]?e\d+|making[._\-\s]?of[._\-\s]?e\d+)(?:[._\-\s]|$)/i;
/** Normalize a folder/file segment for bonus-pattern matching: lowercase and
* strip common separators so "Making.Of" "makingof". */
function normalizeBonusSegment(segment: string): string {
return String(segment || "").toLowerCase().replace(/[._\-\s]+/g, "");
}
/** Detect if a file path lies inside a bonus/extras subdirectory of the package. /** Detect if a file path lies inside a bonus/extras subdirectory of the package.
* Walks up the path from filePath until packageDir and checks each segment. */ * Walks up the path from filePath until packageDir and checks each segment. */
@ -156,9 +165,9 @@ function isInsideBonusDir(filePath: string, packageDir: string): boolean {
const resolvedCurrent = path.resolve(current); const resolvedCurrent = path.resolve(current);
if (resolvedCurrent === root) return false; if (resolvedCurrent === root) return false;
if (!isPathInsideDir(current, packageDir)) return false; if (!isPathInsideDir(current, packageDir)) return false;
const segment = path.basename(current).toLowerCase(); const normalized = normalizeBonusSegment(path.basename(current));
for (const pattern of BONUS_DIR_PATTERNS) { for (const pattern of BONUS_DIR_NORMALIZED_PATTERNS) {
if (segment.includes(pattern)) return true; if (normalized.includes(pattern)) return true;
} }
const parent = path.dirname(current); const parent = path.dirname(current);
if (!parent || parent === current) break; if (!parent || parent === current) break;

View File

@ -9451,6 +9451,99 @@ describe("download manager", () => {
expect(fs.existsSync(flattenedEpisode)).toBe(true); expect(fs.existsSync(flattenedEpisode)).toBe(true);
}, 20000); }, 20000);
it("detects dot-separated bonus subdirectories (Making.Of, Behind.The.Scenes)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageName = "Breaking.Bad.S05.GERMAN.DL.720p.BluRay.x264-TSCC";
const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(outputDir, { recursive: true });
// Mix of dot-separated bonus subdirs - must all be detected as bonus
const zip = new AdmZip();
zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode"));
zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1"));
zip.addFile("Breaking.Bad.S05.Behind.The.Scenes/AnotherClip.mkv", Buffer.from("bonus-2"));
zip.addFile("Breaking.Bad.S05.Deleted.Scenes/DeletedClip.mkv", Buffer.from("bonus-3"));
zip.addFile("Breaking.Bad.S05.Gag.Reel/GagClip.mkv", Buffer.from("bonus-4"));
const archivePath = path.join(outputDir, "episode.zip");
zip.writeZip(archivePath);
const archiveSize = fs.statSync(archivePath).size;
const session = emptySession();
const packageId = `${packageName}-pkg`;
const itemId = `${packageName}-item`;
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageName,
outputDir,
extractDir,
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: `https://dummy/${packageName}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: archiveSize,
totalBytes: archiveSize,
progressPercent: 100,
fileName: "episode.zip",
targetPath: archivePath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig (100 MB)",
createdAt,
updatedAt: createdAt
};
const mkvLibraryDir = path.join(root, "mkv-library");
new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
autoRename4sf4sj: false,
collectMkvToLibrary: true,
mkvLibraryDir,
enableIntegrityCheck: false,
cleanupMode: "none"
},
session,
createStoragePaths(path.join(root, "state"))
);
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv");
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
// None of the bonus files should have landed in the flat library
expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false);
// All bonus files must still exist in their respective subfolders
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Gag.Reel", "GagClip.mkv"))).toBe(true);
}, 20000);
it("keeps existing MKV names and appends a suffix while flattening", async () => { it("keeps existing MKV names and appends a suffix while flattening", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);