Previously, only S01E01-style episode codes triggered show-name injection from the folder name. Plain numbered filenames (e.g. "11 - Title.mkv") with a release tag sourced only from the folder (e.g. "[DmonHiro] Digimon Tamers (BD, 1080p)") were left untouched apart from the tag prefix. Also fixes ExtractShowName to strip trailing parenthetical metadata blocks like "(BD, 1080p)", which previously leaked into the derived show name.
351 lines
18 KiB
C#
351 lines
18 KiB
C#
using System.IO;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Futonizer.Services;
|
|
|
|
/// <summary>
|
|
/// Pure static helpers for computing the final output filename from a source
|
|
/// file path, applying release-tag prefixing and show-name injection.
|
|
/// </summary>
|
|
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);
|
|
|
|
// File suffix release tag directly after a bracketed metadata block:
|
|
// "...[HULU WEBDL-1080p][EAC3 5.1][h264]-NTb" → tag = "NTb"
|
|
// Common on naming-bot-style releases that group tech specs into their
|
|
// own brackets and tack the group on with a dash at the very end. The
|
|
// bracket immediately before the dash must itself be recognisable
|
|
// metadata (checked by the caller via IsMetadataBracket), mirroring
|
|
// FileSuffixTagRegex's guard for the bare-word case.
|
|
private static readonly Regex FileSuffixBracketDashTagRegex =
|
|
new(@"\[([^\[\]]+)\]-([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);
|
|
|
|
// Bare leading episode number with no tag of its own on the file (the
|
|
// release tag, if any, comes only from the folder), e.g. "11 - Title.mkv",
|
|
// "06_Title.mkv", "06.Title.mkv". Requires a separator right after the
|
|
// digits so resolution/quality tokens glued to more characters (e.g.
|
|
// "1080p") aren't mistaken for an episode number lead-in.
|
|
private static readonly Regex BareLeadingEpisodeNumberRegex =
|
|
new(@"^\d{1,3}(?=[\s._-])", 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
|
|
];
|
|
|
|
// Whole-phrase fansub/release descriptor patterns, checked against an
|
|
// entire bracket's contents rather than word-by-word. Catches common
|
|
// multi-word descriptors (e.g. "Multiple Subtitle", "Dual Audio") whose
|
|
// individual words wouldn't otherwise match any MetadataPatterns entry,
|
|
// so they'd be mistaken for a release group name.
|
|
private static readonly Regex[] DescriptorPhrasePatterns =
|
|
[
|
|
new(@"^Multi(ple)?[\s-]?Sub(s|title|titles)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
|
new(@"^(Dual|Multi)[\s-]?Audio$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
|
new(@"^(Hard|Soft)[\s-]?sub(s)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
|
new(@"^(Uncensored|Censored|Batch|Complete)$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
|
];
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Computes the final output filename for a given source file path, applying
|
|
/// (in order):
|
|
/// <list type="number">
|
|
/// <item>Filename suffix tag extraction — a trailing <c>-GroupName</c> on
|
|
/// the filename stem, a group tacked on after a bracketed metadata
|
|
/// block (e.g. <c>[h264]-NTb</c>), or a release group tucked in as
|
|
/// the last word of a trailing <c>(...)</c> metadata block (e.g.
|
|
/// Amazon rips like <c>(1080p AMZN WEB-DL x265 Celdra)</c>), is
|
|
/// stripped and promoted to a bracket prefix. Takes priority over
|
|
/// any folder-derived tag.</item>
|
|
/// <item>Show-name injection — if the (now tag-free) filename starts with
|
|
/// an episode code (e.g. S01E01), or with a bare episode number
|
|
/// (e.g. "11 - Title") once a release tag has been resolved, the
|
|
/// show name is derived from the parent folder and prepended.</item>
|
|
/// <item>Release-tag prefix — a <c>[TAG]</c> bracket prefix, sourced from
|
|
/// the filename suffix, the folder suffix, or the folder prefix,
|
|
/// in that priority order.</item>
|
|
/// </list>
|
|
/// Returns the original filename unchanged when no transformations apply.
|
|
/// </summary>
|
|
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) ?? "");
|
|
|
|
// Capture the file's own pre-existing bracket prefix (if any) before any
|
|
// transformation below, so we never stack a second/different tag on top
|
|
// of a file that already carries its own release-group prefix.
|
|
var originalPrefixMatch = PrefixTagRegex.Match(stemNoExt);
|
|
string? originalPrefixTag = originalPrefixMatch.Success ? originalPrefixMatch.Groups[1].Value : null;
|
|
|
|
// 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);
|
|
var bracketDashMatch = FileSuffixBracketDashTagRegex.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 if (bracketDashMatch.Success
|
|
&& IsMetadataBracket(bracketDashMatch.Groups[1].Value)
|
|
&& !IsMetadataToken(bracketDashMatch.Groups[2].Value))
|
|
{
|
|
// 1a. Extract a release group tacked on after a bracketed metadata
|
|
// block, e.g. "...[h264]-NTb" → tag="NTb", stem="...[h264]".
|
|
fileTag = bracketDashMatch.Groups[2].Value;
|
|
stemNoExt = stemNoExt[..(bracketDashMatch.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..];
|
|
}
|
|
}
|
|
}
|
|
// Pattern C: file has no tag of its own — the tag came only from
|
|
// the folder — but the filename still starts with a bare
|
|
// episode number, e.g. "11 - Title.mkv" in a
|
|
// "[DmonHiro] Show Name (BD, 1080p)" folder. Inject the show
|
|
// name here; the tag is prepended separately in step 4.
|
|
else if (BareLeadingEpisodeNumberRegex.IsMatch(stemNoExt))
|
|
{
|
|
string showName = ExtractShowName(folderName);
|
|
if (!string.IsNullOrEmpty(showName))
|
|
fileName = showName + " " + fileName;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Prepend release tag if not already present. Skip when the file
|
|
// already carries its own (different) bracket prefix — never stack
|
|
// a second tag on top of an existing one.
|
|
if (!string.IsNullOrEmpty(tag))
|
|
{
|
|
bool differsFromOriginalTag = originalPrefixTag != null
|
|
&& !string.Equals(originalPrefixTag, tag, StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!differsFromOriginalTag)
|
|
{
|
|
string tagPrefix = $"[{tag}]";
|
|
if (!fileName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase))
|
|
fileName = tagPrefix + " " + fileName;
|
|
}
|
|
}
|
|
|
|
return fileName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the release tag string (without brackets) from a folder name,
|
|
/// checking (in priority order): a dash suffix (<c>-TAG</c>), a bracketed
|
|
/// suffix (<c>[tag]</c> at the end), then a bracketed prefix (<c>[tag]</c>
|
|
/// at the start). Returns <c>null</c> when none are present.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Derives the show name from a folder name by stripping any release tag
|
|
/// (prefix or suffix) and then:
|
|
/// <list type="bullet">
|
|
/// <item>If the remainder contains brackets (e.g. <c>[BD][1080p-FLAC]</c>),
|
|
/// returns the text that precedes the first bracket.</item>
|
|
/// <item>Otherwise splits on spaces and collects words up to the first
|
|
/// recognised metadata token (season, quality, source, codec, etc.).</item>
|
|
/// </list>
|
|
/// </summary>
|
|
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();
|
|
|
|
// Strip a trailing parenthesised metadata block, e.g. "(BD, 1080p)",
|
|
// when every comma/space-separated word inside it is recognisable
|
|
// quality/source metadata — so a real release group or descriptive
|
|
// parenthetical left at the end of the name isn't mistakenly dropped.
|
|
var trailingParenMatch = TrailingParenGroupRegex.Match(name);
|
|
if (trailingParenMatch.Success)
|
|
{
|
|
var parenWords = trailingParenMatch.Groups[1].Value
|
|
.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parenWords.Length > 0 && parenWords.All(IsMetadataToken))
|
|
name = name[..trailingParenMatch.Index].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<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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]").
|
|
/// </summary>
|
|
private static bool IsMetadataBracket(string content)
|
|
{
|
|
string trimmed = content.Trim();
|
|
if (DescriptorPhrasePatterns.Any(p => p.IsMatch(trimmed))) return true;
|
|
|
|
var parts = trimmed.Split(new[] { ' ', '-' }, StringSplitOptions.RemoveEmptyEntries);
|
|
return parts.Length > 0 && parts.All(IsMetadataToken);
|
|
}
|
|
}
|