diff --git a/Futonizer.csproj b/Futonizer.csproj
index c44dbbf..da12e26 100644
--- a/Futonizer.csproj
+++ b/Futonizer.csproj
@@ -9,8 +9,8 @@
enable
Futonizer
Futonizer
- 1.0.12
- 1.0.12.0
+ 1.0.13
+ 1.0.13.0
Assets\app.ico
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index e5633d3..de8e0aa 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -266,7 +266,7 @@ public partial class MainWindow : FluentWindow
if (matchedSubtitles > 0 || 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.";
}
}
@@ -275,22 +275,32 @@ public partial class MainWindow : FluentWindow
///
/// 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
- /// queued file when there's exactly one and no episode code 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.
+ /// share the same episode. Prefers a full season+episode code (e.g.
+ /// S04E06) when the subtitle's filename has one; otherwise falls back to
+ /// matching by bare episode number alone (e.g. "06", "6", "16", "E06"),
+ /// season-agnostic. Falls back further to the single queued file when
+ /// 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.
///
private bool MatchSubtitleToQueue(string 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 targets;
- if (code != null)
+ if (subCode != null)
{
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();
}
else if (_fileQueue.Count == 1)
@@ -312,19 +322,29 @@ public partial class MainWindow : FluentWindow
///
/// Called whenever a new video is added to the queue: checks the pending
- /// (previously unmatched) subtitle files for one sharing this video's
- /// episode code, and attaches + removes any matches from the pending list.
+ /// (previously unmatched) subtitle files for one matching this video's
+ /// 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.
///
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
{
if (_pendingSubtitles.Count == 0) return;
- string? code = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
- if (code == null) return;
+ string? videoCode = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
+ int? videoEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(item.FileName);
+ if (videoCode == null && videoEpisodeNumber == null) return;
- var matches = _pendingSubtitles
- .Where(p => string.Equals(SubtitleMatcher.ExtractEpisodeCode(Path.GetFileName(p)), code, StringComparison.OrdinalIgnoreCase))
- .ToList();
+ var matches = _pendingSubtitles.Where(p =>
+ {
+ 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)
{
diff --git a/Models/ExternalSubtitleTrack.cs b/Models/ExternalSubtitleTrack.cs
index 96ae994..842fb1c 100644
--- a/Models/ExternalSubtitleTrack.cs
+++ b/Models/ExternalSubtitleTrack.cs
@@ -6,8 +6,9 @@ namespace Futonizer.Models;
///
/// Represents one external subtitle file that was drag-and-dropped in and
-/// matched to a by episode code (e.g. S04E06),
-/// to be muxed in as an additional subtitle track when the file is processed.
+/// matched to a by episode (e.g. "S04E06", or
+/// just a bare episode number like "06"), to be muxed in as an additional
+/// subtitle track when the file is processed.
///
public class ExternalSubtitleTrack : INotifyPropertyChanged
{
diff --git a/Models/QueuedFileItem.cs b/Models/QueuedFileItem.cs
index 839eca4..efbab97 100644
--- a/Models/QueuedFileItem.cs
+++ b/Models/QueuedFileItem.cs
@@ -45,8 +45,9 @@ public class QueuedFileItem : INotifyPropertyChanged
///
/// External subtitle files (dropped separately and matched to this file by
- /// episode code, e.g. S04E06) that will be muxed in as additional
- /// subtitle tracks when this file is processed.
+ /// episode — e.g. "S04E06", or just a bare episode number like "06") that
+ /// will be muxed in as additional subtitle tracks when this file is
+ /// processed.
///
public ObservableCollection ExternalSubtitles { get; } = new();
diff --git a/Services/SubtitleMatcher.cs b/Services/SubtitleMatcher.cs
index f832bdc..a95edc2 100644
--- a/Services/SubtitleMatcher.cs
+++ b/Services/SubtitleMatcher.cs
@@ -4,9 +4,11 @@ using System.Text.RegularExpressions;
namespace Futonizer.Services;
///
-/// Recognises subtitle files by extension and extracts a normalized episode
-/// code (e.g. "S04E06") from a filename, used to match dropped subtitle
-/// files to the video file with the same episode code.
+/// 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
{
@@ -39,11 +41,43 @@ public static class SubtitleMatcher
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 episode code is found.
+ /// 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)
{
@@ -58,4 +92,50 @@ public static class SubtitleMatcher
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;
+ }
}