using System.IO; using System.Text.RegularExpressions; namespace Futonizer.Services; /// /// Pure static helpers for computing the final output filename from a source /// file path, applying release-tag prefixing and show-name injection. /// public static class FileNameHelper { // Folder suffix release tag: "Show Name-TTGA" → tag = "TTGA" // All-uppercase, 2-8 chars, at end of folder name. private static readonly Regex FolderSuffixTagRegex = new(@"-([A-Z][A-Z0-9]{1,7})$", RegexOptions.Compiled); // Folder suffix bracket release tag: "Show Name ... x264 [i_c]" → tag = "i_c" // A bracketed tag at the very end of the folder name (as opposed to // PrefixTagRegex, which matches one at the start). private static readonly Regex FolderSuffixBracketTagRegex = new(@"\[([^\[\]]+)\]$", RegexOptions.Compiled); // File suffix release tag: "…HEVC-DemiHuman" → tag = "DemiHuman" // Mixed case, 3-21 chars (no dots/spaces), at end of filename stem. // Min 3 chars avoids false-positives like "-v2" or "-HD". The word // immediately preceding the dash must itself be recognisable quality/ // source/codec metadata (checked by the caller via IsMetadataToken), so // an ordinary hyphenated title word (e.g. "Scary-oke") is never mistaken // for a "-GroupName" suffix. private static readonly Regex FileSuffixTagRegex = new(@"(?:^|[\s.])([A-Za-z0-9]+)-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled); // Trailing parenthesised metadata block ending in the release group as its // last word, e.g. "... (1080p AMZN WEB-DL x265 Celdra)" → group="Celdra". // Common on Amazon/streaming rips that don't set the group off with a // "-" or its own brackets. private static readonly Regex TrailingParenGroupRegex = new(@"\(([^()]*)\)$", RegexOptions.Compiled); // Prefix release tag: "[KAA] Show Name" → tag = "KAA" private static readonly Regex PrefixTagRegex = new(@"^\[(.+?)\]", RegexOptions.Compiled); // Episode-only filename (no tag prefix): starts with S01E01, S1E1, etc. private static readonly Regex EpisodeOnlyRegex = new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled); // Episode start after a tag prefix is stripped: plain number OR S01E01 style. // e.g. "03 - Deus Vult ..." or "S01E01-Deep Blue" private static readonly Regex EpisodeStartRegex = new(@"^(\d+|S\d+E\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); // Individual metadata-token patterns used to find where the show name ends // inside a folder name. private static readonly Regex[] MetadataPatterns = [ new(@"^S\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01 new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01E01... new(@"^E[Pp]?\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // E01, EP01 new(@"^\d{3,4}[pP]$", RegexOptions.Compiled), // 1080p, 720p new(@"^(4K|UHD|2160p)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // 4K, UHD new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources new(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs new(@"^(FLAC|AAC|DTS(-HD)?|TrueHD|Atmos|E?AC3|DDP?|Opus|MP3)(\d(\.\d)?)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs (+ optional channel suffix, e.g. DDP5.1) new(@"^\d\.\d$", RegexOptions.Compiled), // bare channel count, e.g. 5.1 new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth ]; // ------------------------------------------------------------------------- /// /// Computes the final output filename for a given source file path, applying /// (in order): /// /// Filename suffix tag extraction — a trailing -GroupName on /// the filename stem, or a release group tucked in as the last word /// of a trailing (...) metadata block (e.g. Amazon rips like /// (1080p AMZN WEB-DL x265 Celdra)), is stripped and promoted /// to a bracket prefix. Takes priority over any folder-derived tag. /// Show-name injection — if the (now tag-free) filename starts with /// an episode code (e.g. S01E01) the show name is derived from the /// parent folder and prepended. /// Release-tag prefix — a [TAG] bracket prefix, sourced from /// the filename suffix, the folder suffix, or the folder prefix, /// in that priority order. /// /// Returns the original filename unchanged when no transformations apply. /// public static string ComputeOutputFileName(string sourceFilePath) { string fileName = Path.GetFileName(sourceFilePath); string ext = Path.GetExtension(fileName); string stemNoExt = Path.GetFileNameWithoutExtension(fileName); string? folder = Path.GetDirectoryName(sourceFilePath); string folderName = string.IsNullOrEmpty(folder) ? "" : (Path.GetFileName(folder) ?? ""); // 1. Extract in-filename suffix tag and remove it from the stem. // e.g. "...x264-DemiHuman" → tag="DemiHuman", stem="...x264" // Only fires when the word right before the dash is itself a // recognised metadata token (codec/source/etc.), so ordinary // hyphenated title words are left alone. string? fileTag = null; var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt); if (fileSuffixMatch.Success && IsMetadataToken(fileSuffixMatch.Groups[1].Value) && !IsMetadataToken(fileSuffixMatch.Groups[2].Value)) { fileTag = fileSuffixMatch.Groups[2].Value; stemNoExt = stemNoExt[..(fileSuffixMatch.Groups[2].Index - 1)]; fileName = stemNoExt + ext; } else { // 1b. Extract a release group tucked in as the last word of a // trailing "(...)" metadata block, e.g. // "... (1080p AMZN WEB-DL x265 Celdra)" → tag="Celdra", // stem="... (1080p AMZN WEB-DL x265)". var parenMatch = TrailingParenGroupRegex.Match(stemNoExt); if (parenMatch.Success) { string parenContent = parenMatch.Groups[1].Value; var words = parenContent.Split(' ', StringSplitOptions.RemoveEmptyEntries); // Only treat the last word as a release group if the rest of the // block is recognisable quality/source/codec metadata — this // avoids misfiring on unrelated parentheticals like "(2020)" or // "(Dual Audio)" that don't carry a group name. if (words.Length >= 2 && !IsMetadataToken(words[^1]) && words[..^1].Any(IsMetadataToken)) { fileTag = words[^1]; string remaining = string.Join(" ", words[..^1]); string beforeParen = stemNoExt[..parenMatch.Index].TrimEnd(); stemNoExt = string.IsNullOrEmpty(remaining) ? beforeParen : $"{beforeParen} ({remaining})"; fileName = stemNoExt + ext; } } } // 2. Determine the release tag: filename suffix > folder tag. string? tag = fileTag; if (string.IsNullOrEmpty(tag) && !string.IsNullOrEmpty(folderName)) tag = ExtractReleaseTag(folderName); // 3. Check for episode-only patterns and inject show name if needed. if (!string.IsNullOrEmpty(folderName)) { // Pattern A: untagged stem starts with S01E01 (e.g. "S01E01-Deep Blue.mkv") if (EpisodeOnlyRegex.IsMatch(stemNoExt)) { string showName = ExtractShowName(folderName); if (!string.IsNullOrEmpty(showName)) fileName = showName + " " + fileName; } // Pattern B: file already carries [TAG] but episode number follows directly. // e.g. "[WBDP] 03 - Deus Vult ..." → insert show name after the tag. else if (!string.IsNullOrEmpty(tag)) { string tagPfx = $"[{tag}] "; if (stemNoExt.StartsWith(tagPfx, StringComparison.OrdinalIgnoreCase)) { string afterTag = stemNoExt[tagPfx.Length..]; if (EpisodeStartRegex.IsMatch(afterTag)) { string showName = ExtractShowName(folderName); if (!string.IsNullOrEmpty(showName)) { // Strip the tag, inject show name; tag is re-prepended in step 4. fileName = showName + " " + fileName[tagPfx.Length..]; } } } } } // 4. Prepend release tag if not already present. if (!string.IsNullOrEmpty(tag)) { string tagPrefix = $"[{tag}]"; if (!fileName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase)) fileName = tagPrefix + " " + fileName; } return fileName; } /// /// Extracts the release tag string (without brackets) from a folder name, /// checking (in priority order): a dash suffix (-TAG), a bracketed /// suffix ([tag] at the end), then a bracketed prefix ([tag] /// at the start). Returns null when none are present. /// public static string? ExtractReleaseTag(string folderName) { var suffixMatch = FolderSuffixTagRegex.Match(folderName); if (suffixMatch.Success && !IsMetadataToken(suffixMatch.Groups[1].Value)) return suffixMatch.Groups[1].Value; var bracketSuffixMatch = FolderSuffixBracketTagRegex.Match(folderName); if (bracketSuffixMatch.Success && !IsMetadataBracket(bracketSuffixMatch.Groups[1].Value)) return bracketSuffixMatch.Groups[1].Value; var prefixMatch = PrefixTagRegex.Match(folderName); if (prefixMatch.Success && !IsMetadataBracket(prefixMatch.Groups[1].Value)) return prefixMatch.Groups[1].Value; return null; } /// /// Derives the show name from a folder name by stripping any release tag /// (prefix or suffix) and then: /// /// If the remainder contains brackets (e.g. [BD][1080p-FLAC]), /// returns the text that precedes the first bracket. /// Otherwise splits on spaces and collects words up to the first /// recognised metadata token (season, quality, source, codec, etc.). /// /// public static string ExtractShowName(string folderName) { // Strip release tags first. string name = FolderSuffixTagRegex.Replace(folderName, "").Trim(); name = FolderSuffixBracketTagRegex.Replace(name, "").Trim(); name = PrefixTagRegex.Replace(name, "").Trim(); // Nothing left, or what remains immediately starts with a bracket — no show name. if (string.IsNullOrEmpty(name) || name[0] == '[') return string.Empty; // Bracket-style metadata: "The Saga of Tanya the Evil [BD][1080p-FLAC][HEVC]" int bracketIdx = name.IndexOf('['); if (bracketIdx > 0) return name[..bracketIdx].Trim(); // Space-separated metadata: "Grand Blue Dreaming S01 1080p BD Remux FLAC" var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries); var showWords = new List(); foreach (var word in words) { if (IsMetadataToken(word)) break; showWords.Add(word); } return string.Join(" ", showWords).Trim(); } private static bool IsMetadataToken(string token) { foreach (var pattern in MetadataPatterns) if (pattern.IsMatch(token)) return true; return false; } /// /// True when every word inside a bracketed block is recognisable quality/ /// source/codec metadata (e.g. "[HEVC]", "[1080p-FLAC]"), meaning it's /// just another metadata tag rather than an actual release group name /// (e.g. "[i_c]"). /// private static bool IsMetadataBracket(string content) { var parts = content.Split(new[] { ' ', '-' }, StringSplitOptions.RemoveEmptyEntries); return parts.Length > 0 && parts.All(IsMetadataToken); } }