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; + } }