using System.IO; using System.Text.RegularExpressions; namespace Futonizer.Services; /// /// Recognises subtitle files by extension and extracts episode information /// from a filename — either a normalized season+episode code (e.g. /// "S04E06") or, failing that, a bare episode number (e.g. "06", "6", /// "16") — used to match dropped subtitle files to the video file for the /// same episode. /// public static class SubtitleMatcher { /// /// Every subtitle container/format extension mkvmerge (and therefore /// Futonizer) can mux in: SubRip, (Advanced) SubStation Alpha, VobSub, /// WebVTT, HDMV/PGS, Universal Subtitle Format, SAMI, TTML/DFXP, EBU-STL, /// MicroDVD/SubViewer (both commonly saved with a plain ".sub" extension), /// and Kate. /// public static readonly string[] SupportedExtensions = { ".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", ".webvtt", ".sup", ".pgs", ".usf", ".smi", ".sami", ".ttml", ".dfxp", ".stl", ".kate", }; public static bool IsSubtitleFile(string path) { string ext = Path.GetExtension(path); return !string.IsNullOrEmpty(ext) && SupportedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase); } // "S04E06", "S4E6", "S04 E06", "S04-E06", "S04.E06" — the standard scene/anime tagging scheme. private static readonly Regex SeasonEpisodeRegex = new(@"S(\d{1,4})[\s._-]*E(\d{1,4})", RegexOptions.IgnoreCase | RegexOptions.Compiled); // Fallback: "4x06" style season x episode separator. private static readonly Regex SeasonXEpisodeRegex = new(@"(? /// Extracts and normalizes the episode code from a filename (e.g. /// "Show.S4E6.1080p.mkv" and "Show - 04x06 - Title.srt" both return /// "S04E06"), so files can be matched regardless of zero-padding or /// separator style. Returns null if no season+episode code is found — /// use for the season-agnostic /// fallback (plain episode numbers like "06"). /// public static string? ExtractEpisodeCode(string fileName) { var match = SeasonEpisodeRegex.Match(fileName); if (!match.Success) match = SeasonXEpisodeRegex.Match(fileName); if (!match.Success) return null; if (!int.TryParse(match.Groups[1].Value, out int season)) return null; if (!int.TryParse(match.Groups[2].Value, out int episode)) return null; return $"S{season:D2}E{episode:D2}"; } /// /// Extracts just the episode number from a filename, ignoring season. /// Tries, in order: a full season+episode code (e.g. "S04E06" → 6), an /// explicit marker with no season ("E06", "EP06", "Episode 06" → 6), or /// a bare number that is either the entire filename stem ("06", "6", /// "16") or cleanly delimited from the rest of the name by a separator /// ("06 - Title", "Show - 06"). Bare numbers are capped at a few digits /// so resolutions (1080p), years, and bitrates aren't mistaken for an /// episode number. Returns null if nothing usable is found. /// public static int? ExtractEpisodeNumber(string fileName) { string stem = Path.GetFileNameWithoutExtension(fileName); var seasonMatch = SeasonEpisodeRegex.Match(stem); if (!seasonMatch.Success) seasonMatch = SeasonXEpisodeRegex.Match(stem); if (seasonMatch.Success && int.TryParse(seasonMatch.Groups[2].Value, out int fromSeasonCode)) return fromSeasonCode; var markerMatch = ExplicitEpisodeMarkerRegex.Match(stem); if (markerMatch.Success && int.TryParse(markerMatch.Groups[1].Value, out int fromMarker)) return fromMarker; if (BareWholeNumberRegex.IsMatch(stem) && int.TryParse(stem, out int wholeStem)) return wholeStem; var leadingMatch = BareLeadingNumberRegex.Match(stem); if (leadingMatch.Success && int.TryParse(leadingMatch.Groups[1].Value, out int fromLeading)) return fromLeading; var dashMatch = DashDelimitedNumberRegex.Match(stem); if (dashMatch.Success) { string dashValue = dashMatch.Groups[1].Success ? dashMatch.Groups[1].Value : dashMatch.Groups[2].Value; if (int.TryParse(dashValue, out int fromDash)) return fromDash; } var trailingMatch = BareTrailingNumberRegex.Match(stem); if (trailingMatch.Success && int.TryParse(trailingMatch.Groups[1].Value, out int fromTrailing)) return fromTrailing; return null; } }