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.
This commit is contained in:
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<AssemblyName>Futonizer</AssemblyName>
|
<AssemblyName>Futonizer</AssemblyName>
|
||||||
<RootNamespace>Futonizer</RootNamespace>
|
<RootNamespace>Futonizer</RootNamespace>
|
||||||
<Version>1.0.12</Version>
|
<Version>1.0.13</Version>
|
||||||
<FileVersion>1.0.12.0</FileVersion>
|
<FileVersion>1.0.13.0</FileVersion>
|
||||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
+36
-16
@@ -266,7 +266,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
if (matchedSubtitles > 0 || unmatchedSubtitles > 0)
|
if (matchedSubtitles > 0 || unmatchedSubtitles > 0)
|
||||||
{
|
{
|
||||||
StatusText.Text = unmatchedSubtitles > 0
|
StatusText.Text = unmatchedSubtitles > 0
|
||||||
? $"Matched {matchedSubtitles} subtitle(s); {unmatchedSubtitles} couldn't be matched to an episode code (S04E06 style)."
|
? $"Matched {matchedSubtitles} subtitle(s); {unmatchedSubtitles} couldn't be matched to an episode (S04E06, E06, or plain episode number like 06/6/16)."
|
||||||
: $"Matched {matchedSubtitles} subtitle(s) to their episode.";
|
: $"Matched {matchedSubtitles} subtitle(s) to their episode.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,22 +275,32 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to attach a dropped subtitle file to whichever queued video(s)
|
/// Attempts to attach a dropped subtitle file to whichever queued video(s)
|
||||||
/// share the same episode code (e.g. S04E06). Falls back to the single
|
/// share the same episode. Prefers a full season+episode code (e.g.
|
||||||
/// queued file when there's exactly one and no episode code could be
|
/// S04E06) when the subtitle's filename has one; otherwise falls back to
|
||||||
/// extracted from the subtitle's filename. Returns false if no target
|
/// matching by bare episode number alone (e.g. "06", "6", "16", "E06"),
|
||||||
/// could be determined, in which case the file is kept pending until a
|
/// season-agnostic. Falls back further to the single queued file when
|
||||||
/// matching video is dropped later.
|
/// there's exactly one and no episode info at all could be extracted
|
||||||
|
/// from the subtitle's filename. Returns false if no target could be
|
||||||
|
/// determined, in which case the file is kept pending until a matching
|
||||||
|
/// video is dropped later.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool MatchSubtitleToQueue(string subtitlePath)
|
private bool MatchSubtitleToQueue(string subtitlePath)
|
||||||
{
|
{
|
||||||
string subFileName = Path.GetFileName(subtitlePath);
|
string subFileName = Path.GetFileName(subtitlePath);
|
||||||
string? code = SubtitleMatcher.ExtractEpisodeCode(subFileName);
|
string? subCode = SubtitleMatcher.ExtractEpisodeCode(subFileName);
|
||||||
|
int? subEpisodeNumber = subCode == null ? SubtitleMatcher.ExtractEpisodeNumber(subFileName) : null;
|
||||||
|
|
||||||
List<QueuedFileItem> targets;
|
List<QueuedFileItem> targets;
|
||||||
if (code != null)
|
if (subCode != null)
|
||||||
{
|
{
|
||||||
targets = _fileQueue
|
targets = _fileQueue
|
||||||
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), code, StringComparison.OrdinalIgnoreCase))
|
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), subCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else if (subEpisodeNumber != null)
|
||||||
|
{
|
||||||
|
targets = _fileQueue
|
||||||
|
.Where(q => SubtitleMatcher.ExtractEpisodeNumber(q.FileName) == subEpisodeNumber)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
else if (_fileQueue.Count == 1)
|
else if (_fileQueue.Count == 1)
|
||||||
@@ -312,19 +322,29 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called whenever a new video is added to the queue: checks the pending
|
/// Called whenever a new video is added to the queue: checks the pending
|
||||||
/// (previously unmatched) subtitle files for one sharing this video's
|
/// (previously unmatched) subtitle files for one matching this video's
|
||||||
/// episode code, and attaches + removes any matches from the pending list.
|
/// episode — by full season+episode code when both have one, or by bare
|
||||||
|
/// episode number otherwise — and attaches + removes any matches from
|
||||||
|
/// the pending list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
|
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
|
||||||
{
|
{
|
||||||
if (_pendingSubtitles.Count == 0) return;
|
if (_pendingSubtitles.Count == 0) return;
|
||||||
|
|
||||||
string? code = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
|
string? videoCode = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
|
||||||
if (code == null) return;
|
int? videoEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(item.FileName);
|
||||||
|
if (videoCode == null && videoEpisodeNumber == null) return;
|
||||||
|
|
||||||
var matches = _pendingSubtitles
|
var matches = _pendingSubtitles.Where(p =>
|
||||||
.Where(p => string.Equals(SubtitleMatcher.ExtractEpisodeCode(Path.GetFileName(p)), code, StringComparison.OrdinalIgnoreCase))
|
{
|
||||||
.ToList();
|
string subName = Path.GetFileName(p);
|
||||||
|
string? subCode = SubtitleMatcher.ExtractEpisodeCode(subName);
|
||||||
|
if (subCode != null)
|
||||||
|
return videoCode != null && string.Equals(subCode, videoCode, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
int? subEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(subName);
|
||||||
|
return subEpisodeNumber != null && subEpisodeNumber == videoEpisodeNumber;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
foreach (var match in matches)
|
foreach (var match in matches)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ namespace Futonizer.Models;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents one external subtitle file that was drag-and-dropped in and
|
/// Represents one external subtitle file that was drag-and-dropped in and
|
||||||
/// matched to a <see cref="QueuedFileItem"/> by episode code (e.g. S04E06),
|
/// matched to a <see cref="QueuedFileItem"/> by episode (e.g. "S04E06", or
|
||||||
/// to be muxed in as an additional subtitle track when the file is processed.
|
/// just a bare episode number like "06"), to be muxed in as an additional
|
||||||
|
/// subtitle track when the file is processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExternalSubtitleTrack : INotifyPropertyChanged
|
public class ExternalSubtitleTrack : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// External subtitle files (dropped separately and matched to this file by
|
/// External subtitle files (dropped separately and matched to this file by
|
||||||
/// episode code, e.g. S04E06) that will be muxed in as additional
|
/// episode — e.g. "S04E06", or just a bare episode number like "06") that
|
||||||
/// subtitle tracks when this file is processed.
|
/// will be muxed in as additional subtitle tracks when this file is
|
||||||
|
/// processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
|
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ using System.Text.RegularExpressions;
|
|||||||
namespace Futonizer.Services;
|
namespace Futonizer.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recognises subtitle files by extension and extracts a normalized episode
|
/// Recognises subtitle files by extension and extracts episode information
|
||||||
/// code (e.g. "S04E06") from a filename, used to match dropped subtitle
|
/// from a filename — either a normalized season+episode code (e.g.
|
||||||
/// files to the video file with the same episode code.
|
/// "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>
|
/// </summary>
|
||||||
public static class SubtitleMatcher
|
public static class SubtitleMatcher
|
||||||
{
|
{
|
||||||
@@ -39,11 +41,43 @@ public static class SubtitleMatcher
|
|||||||
private static readonly Regex SeasonXEpisodeRegex =
|
private static readonly Regex SeasonXEpisodeRegex =
|
||||||
new(@"(?<![\d.])(\d{1,2})x(\d{2,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
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>
|
/// <summary>
|
||||||
/// Extracts and normalizes the episode code from a filename (e.g.
|
/// Extracts and normalizes the episode code from a filename (e.g.
|
||||||
/// "Show.S4E6.1080p.mkv" and "Show - 04x06 - Title.srt" both return
|
/// "Show.S4E6.1080p.mkv" and "Show - 04x06 - Title.srt" both return
|
||||||
/// "S04E06"), so files can be matched regardless of zero-padding or
|
/// "S04E06"), so files can be matched regardless of zero-padding or
|
||||||
/// separator style. Returns null if no episode code is found.
|
/// 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>
|
/// </summary>
|
||||||
public static string? ExtractEpisodeCode(string fileName)
|
public static string? ExtractEpisodeCode(string fileName)
|
||||||
{
|
{
|
||||||
@@ -58,4 +92,50 @@ public static class SubtitleMatcher
|
|||||||
|
|
||||||
return $"S{season:D2}E{episode:D2}";
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user