Files
l4kr 05fbe34e20 Match subtitles to episodes by plain episode number, not just S04E06
Adds a season-agnostic fallback to subtitle-episode matching so dropped
subtitle files no longer need a full SxxEyy code to be attached to the
right video. Recognises bare numbers (06, 6, 16), explicit markers
without a season (E06, EP06, Episode 06), and numbers cleanly delimited
by separators, including the common anime release style 'Show - 06 -
Title'. Guards against false positives from resolutions (1080p), years,
and decimal-looking audio tags (DD5.1) by requiring clean separator
boundaries and capping bare numbers at a few digits.

Full season+episode codes still take priority and match exactly as
before; the new bare-number matching only kicks in when one side of the
match lacks season info.

Note: dotnet is not reachable in this environment, so the required
Release build validation could not be run before this commit.
2026-07-24 12:10:06 +00:00

142 lines
6.4 KiB
C#

using System.IO;
using System.Text.RegularExpressions;
namespace Futonizer.Services;
/// <summary>
/// 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.
/// </summary>
public static class SubtitleMatcher
{
/// <summary>
/// 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.
/// </summary>
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(@"(?<![\d.])(\d{1,2})x(\d{2,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Explicit episode marker with no season prefix: "E06", "EP06", "Episode 06".
// Requires a non-alphanumeric character (or start of string) right before
// the marker so it doesn't fire on the "e" inside an unrelated word.
private static readonly Regex ExplicitEpisodeMarkerRegex =
new(@"(?:^|[^A-Za-z0-9])(?:episode|ep|e)[\s._-]*(\d{1,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Whole filename stem is just a number, e.g. "06", "6", "16".
private static readonly Regex BareWholeNumberRegex =
new(@"^\d{1,4}$", RegexOptions.Compiled);
// A number cleanly delimited at the very start of the stem by a
// separator, e.g. "06 - Title", "06_Title", "06.Title".
private static readonly Regex BareLeadingNumberRegex =
new(@"^(\d{1,3})(?=[\s._-])", RegexOptions.Compiled);
// A number delimited by a " - " (or "-") release-style dash separator
// that isn't at the very end of the stem, e.g. "Show - 06 - Title",
// "[Group] Show Name - 06 [1080p]". Requires whitespace immediately
// before the dash so it doesn't fire on glued tokens like "x264-06".
private static readonly Regex DashDelimitedNumberRegex =
new(@"(?<=\s-\s)(\d{1,3})(?!\d)|(?<=\s-)(\d{1,3})(?!\d)", RegexOptions.Compiled);
// A number cleanly delimited at the very end of the stem by a
// separator, e.g. "Show - 06", "Show_06", "Show.06". A "." only counts
// as a separator here when it isn't itself preceded by a digit, so
// decimal-looking tokens like audio channel counts ("DD5.1") aren't
// mistaken for an episode number.
private static readonly Regex BareTrailingNumberRegex =
new(@"(?<=[\s_-]|(?<!\d)\.)(\d{1,3})$", RegexOptions.Compiled);
/// <summary>
/// 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 <see cref="ExtractEpisodeNumber"/> for the season-agnostic
/// fallback (plain episode numbers like "06").
/// </summary>
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}";
}
/// <summary>
/// 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.
/// </summary>
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;
}
}