Files
Futonizer/MainWindow.xaml.cs
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

709 lines
26 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Futonizer.Models;
using Futonizer.Services;
using Futonizer.Views;
using Wpf.Ui.Controls;
namespace Futonizer;
public partial class MainWindow : FluentWindow
{
private const int MaxConcurrentScans = 10;
private const int MaxConcurrentStrips = 3;
private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly List<string> _pendingSubtitles = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing;
private bool _propagatingSelection;
public MainWindow()
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
FileQueueList.ItemsSource = _fileQueue;
_fileQueue.CollectionChanged += (_, _) => UpdateDropHint();
LoadSettingsIntoUi();
Closing += (_, _) => SaveSettingsFromUi();
Loaded += MainWindow_Loaded;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
EnsureRequiredSettingsConfigured();
}
/// <summary>
/// Warns the user and opens Settings if mkvmerge.exe isn't configured (or
/// no longer exists), or if a previously chosen output folder has since
/// disappeared. Runs once on startup; does nothing once everything is
/// already valid.
/// </summary>
private void EnsureRequiredSettingsConfigured()
{
bool mkvMissing = string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath);
bool outputInvalid = !string.IsNullOrWhiteSpace(_settings.OutputFolder) && !Directory.Exists(_settings.OutputFolder);
if (!mkvMissing && !outputInvalid) return;
string message = mkvMissing
? "mkvmerge.exe could not be found. Please locate it to enable stripping tracks."
: "The previously configured output folder no longer exists. Please choose a new one, or leave it blank to overwrite files in place.";
System.Windows.MessageBox.Show(this, message, "Futonizer — Setup required",
System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Warning);
if (outputInvalid)
_settings.OutputFolder = string.Empty;
var win = new SettingsWindow(_settings) { Owner = this };
if (win.ShowDialog() == true && win.Result != null)
{
_settings.MkvMergePath = win.Result.MkvMergePath;
_settings.OutputFolder = win.Result.OutputFolder;
SettingsService.Save(_settings);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static void RestartApplication()
{
try
{
string? exePath = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exePath))
Process.Start(exePath);
}
catch
{
}
System.Windows.Application.Current.Shutdown();
}
private void UpdateDropHint()
{
bool empty = _fileQueue.Count == 0;
DropHint.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
ClearQueueButton.IsEnabled = !empty;
}
/// <summary>
/// Shows a blocking overlay with a simple "loaded / total" progress bar
/// while any queued file is still being scanned, and keeps the Run button
/// disabled until every dropped file has finished loading.
/// </summary>
private void UpdateLoadingOverlay()
{
int total = _fileQueue.Count;
int loaded = _fileQueue.Count(f => !f.IsLoading);
if (total > 0 && loaded < total)
{
LoadingOverlay.Visibility = Visibility.Visible;
LoadingProgressBar.Maximum = total;
LoadingProgressBar.Value = loaded;
LoadingOverlayText.Text = $"Loading files... ({loaded}/{total})";
}
else
{
LoadingOverlay.Visibility = Visibility.Collapsed;
}
UpdateRunButtonEnabled();
}
private void UpdateRunButtonEnabled()
{
bool anyLoading = _fileQueue.Any(f => f.IsLoading);
bool allCorrect = _fileQueue.Count > 0 && _fileQueue.All(f => f.IsCorrect);
RunButton.IsEnabled = !_isProcessing && !anyLoading && allCorrect;
RunButton.ToolTip = allCorrect
? null
: "Every file in the queue needs exactly one audio and one subtitle track selected before stripping.";
UpdateRunButtonLabel();
}
private void UpdateRunButtonLabel()
{
bool anyRename = _fileQueue.Any(f =>
!string.Equals(f.DisplayFileName, f.FileName, StringComparison.Ordinal));
RunButton.Content = anyRename ? "Rename and Strip Tracks" : "Strip Tracks";
}
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(QueuedFileItem.IsLoading))
UpdateLoadingOverlay();
if (e.PropertyName == nameof(QueuedFileItem.LoadState))
UpdateRunButtonEnabled();
}
private static string GuessDefaultMkvMergePath()
{
string[] candidates =
{
@"C:\Program Files\MKVToolNix\mkvmerge.exe",
@"C:\Program Files (x86)\MKVToolNix\mkvmerge.exe",
};
return candidates.FirstOrDefault(File.Exists) ?? string.Empty;
}
// ── Settings persistence ──────────────────────────────────────────────────
private void LoadSettingsIntoUi()
{
_settings = SettingsService.Load();
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath))
_settings.MkvMergePath = GuessDefaultMkvMergePath();
}
private void SaveSettingsFromUi()
{
SettingsService.Save(_settings);
}
// ── Settings button ───────────────────────────────────────────────────────
private void SettingsButton_Click(object sender, RoutedEventArgs e)
{
var win = new SettingsWindow(_settings) { Owner = this };
if (win.ShowDialog() == true && win.Result != null)
{
_settings.MkvMergePath = win.Result.MkvMergePath;
_settings.OutputFolder = win.Result.OutputFolder;
SettingsService.Save(_settings);
RestartApplication();
}
}
// ── Drag & Drop ───────────────────────────────────────────────────────────
private void Window_DragOver(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasAcceptedFile = paths.Any(p =>
File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
}
private static bool IsMkvFile(string path)
=> string.Equals(Path.GetExtension(path), ".mkv", StringComparison.OrdinalIgnoreCase);
private void Window_Drop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
var newVideoPaths = paths
.Where(p => IsMkvFile(p)
&& File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList();
var subtitlePaths = paths
.Where(p => SubtitleMatcher.IsSubtitleFile(p) && File.Exists(p))
.ToList();
var newItems = new List<QueuedFileItem>();
foreach (var path in newVideoPaths)
{
var item = new QueuedFileItem(path);
item.PropertyChanged += QueuedFileItem_PropertyChanged;
_fileQueue.Add(item);
newItems.Add(item);
_ = ScanFileItemAsync(item);
}
// Newly-added videos might satisfy subtitles dropped in earlier (or
// in this same batch, if the subtitle sorts before its video).
foreach (var item in newItems)
MatchPendingSubtitlesToFile(item);
int matchedSubtitles = 0;
foreach (var subPath in subtitlePaths)
{
if (MatchSubtitleToQueue(subPath))
matchedSubtitles++;
else if (!_pendingSubtitles.Contains(subPath, StringComparer.OrdinalIgnoreCase))
_pendingSubtitles.Add(subPath);
}
UpdateLoadingOverlay();
if (newItems.Count > 0)
{
var first = _fileQueue.FirstOrDefault(q =>
string.Equals(q.FilePath, newVideoPaths[0], StringComparison.OrdinalIgnoreCase));
if (first != null) FileQueueList.SelectedItem = first;
}
int unmatchedSubtitles = subtitlePaths.Count - matchedSubtitles;
if (matchedSubtitles > 0 || unmatchedSubtitles > 0)
{
StatusText.Text = unmatchedSubtitles > 0
? $"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.";
}
}
// ── Subtitle matching ───────────────────────────────────────────────────
/// <summary>
/// Attempts to attach a dropped subtitle file to whichever queued video(s)
/// 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.
/// </summary>
private bool MatchSubtitleToQueue(string subtitlePath)
{
string subFileName = Path.GetFileName(subtitlePath);
string? subCode = SubtitleMatcher.ExtractEpisodeCode(subFileName);
int? subEpisodeNumber = subCode == null ? SubtitleMatcher.ExtractEpisodeNumber(subFileName) : null;
List<QueuedFileItem> targets;
if (subCode != null)
{
targets = _fileQueue
.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)
{
targets = new List<QueuedFileItem> { _fileQueue[0] };
}
else
{
targets = new List<QueuedFileItem>();
}
if (targets.Count == 0) return false;
foreach (var target in targets)
AttachExternalSubtitle(target, subtitlePath);
return true;
}
/// <summary>
/// Called whenever a new video is added to the queue: checks the pending
/// (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.
/// </summary>
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
{
if (_pendingSubtitles.Count == 0) 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 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)
{
AttachExternalSubtitle(item, match);
_pendingSubtitles.Remove(match);
}
}
private static void AttachExternalSubtitle(QueuedFileItem target, string subtitlePath)
{
if (target.ExternalSubtitles.Any(s => string.Equals(s.FilePath, subtitlePath, StringComparison.OrdinalIgnoreCase)))
return;
var info = SubtitleLanguageHelper.Detect(Path.GetFileName(subtitlePath));
var sub = new ExternalSubtitleTrack(subtitlePath)
{
Language = info.Language,
TrackName = info.TrackName,
Forced = info.Forced,
HearingImpaired = info.HearingImpaired,
IsDefault = target.ExternalSubtitles.Count == 0,
};
target.ExternalSubtitles.Add(sub);
}
private void RemoveExternalSubtitle_Click(object sender, RoutedEventArgs e)
{
if (sender is not System.Windows.Controls.Button btn || btn.Tag is not ExternalSubtitleTrack sub) return;
var owner = _fileQueue.FirstOrDefault(f => f.ExternalSubtitles.Contains(sub));
if (owner == null) return;
bool wasDefault = sub.IsDefault;
owner.ExternalSubtitles.Remove(sub);
if (wasDefault)
{
var next = owner.ExternalSubtitles.FirstOrDefault();
if (next != null) next.IsDefault = true;
}
if (ReferenceEquals(FileQueueList.SelectedItem, owner))
ExternalSubsPanel.Visibility = owner.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
e.Handled = true;
}
// ── File scanning ─────────────────────────────────────────────────────────
private async Task ScanFileItemAsync(QueuedFileItem item)
{
item.IsScanning = true;
item.Status = "Scanning...";
item.Tracks.Clear();
await _scanSemaphore.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath))
{
item.IsScanning = false;
item.HasError = true;
item.Status = "mkvmerge.exe not found — open Settings";
return;
}
var service = new MkvService(_settings.MkvMergePath);
var info = await service.IdentifyAsync(item.FilePath);
var tracks = service.BuildTrackItems(info);
foreach (var t in tracks)
{
item.Tracks.Add(t);
if (t.Type is "audio" or "subtitles")
{
t.PropertyChanged += (s, e) => OnTrackSelectionChanged(item, (TrackItem)s!, e);
}
}
item.IsScanning = false;
item.Status = $"{tracks.Count} track{(tracks.Count == 1 ? "" : "s")}";
}
catch (Exception ex)
{
item.IsScanning = false;
item.HasError = true;
item.Status = ex.Message;
}
finally
{
_scanSemaphore.Release();
}
}
// ── Audio/subtitle selection propagation ────────────────────────────────────
/// <summary>
/// Propagates audio/subtitle selection/deselection to every other file in
/// the queue:
/// - Checking a track matches it to other files by its exact
/// "Codec/Name" identity:
/// - If the source file had no other track of the same type
/// selected before this check ("adding"), other files that don't
/// have a matching track are left untouched.
/// - If the source file had a different track of the same type
/// selected before this check ("overriding"), other files that
/// don't have a matching track have their selection cleared.
/// - Unchecking a track clears the selection of that type in every
/// other file too, so a deselection always applies everywhere.
/// Within the source file, only one track of a given type may ever be
/// selected.
/// </summary>
private void OnTrackSelectionChanged(QueuedFileItem sourceFile, TrackItem track, PropertyChangedEventArgs e)
{
if (_propagatingSelection) return;
if (e.PropertyName != nameof(TrackItem.Copy)) return;
string type = track.Type;
_propagatingSelection = true;
try
{
if (track.Copy)
{
bool wasOverriding = sourceFile.Tracks.Any(t => t.Type == type && t != track && t.Copy);
foreach (var other in sourceFile.Tracks.Where(t => t.Type == type && t != track))
{
other.Copy = false;
other.DefaultTrack = false;
}
track.DefaultTrack = true;
string key = track.PropagationKey;
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
var match = otherFile.Tracks.FirstOrDefault(t => t.Type == type && t.PropagationKey == key);
if (match != null)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
{
sub.Copy = sub == match;
sub.DefaultTrack = sub == match;
}
}
else if (wasOverriding)
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
{
sub.Copy = false;
sub.DefaultTrack = false;
}
}
}
}
else
{
track.DefaultTrack = false;
foreach (var otherFile in _fileQueue.Where(f => f != sourceFile))
{
foreach (var sub in otherFile.Tracks.Where(t => t.Type == type))
{
sub.Copy = false;
sub.DefaultTrack = false;
}
}
}
}
finally
{
_propagatingSelection = false;
}
}
// ── File queue UI ─────────────────────────────────────────────────────────
private void RemoveFile_Click(object sender, RoutedEventArgs e)
{
if (sender is System.Windows.Controls.Button btn && btn.Tag is QueuedFileItem item)
{
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Remove(item);
UpdateLoadingOverlay();
}
e.Handled = true;
}
/// <summary>
/// Removes every currently-selected item from the queue (multi-select
/// via Ctrl/Shift-click), for batch removal without clearing everything.
/// </summary>
private void FileQueueList_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Delete) return;
var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList();
if (selected.Count == 0) return;
foreach (var item in selected)
{
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Remove(item);
}
UpdateLoadingOverlay();
e.Handled = true;
}
/// <summary>
/// Opens the currently-selected queue item with whatever application
/// Windows has associated with .mkv files (typically the default video
/// player), so the user can quickly preview a file without leaving the app.
/// </summary>
private void FileQueueList_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
}
/// <summary>
/// Same as <see cref="FileQueueList_MouseDoubleClick"/>, but for the track
/// table. Ignores double-clicks on the "Copy" checkbox column so toggling
/// it rapidly doesn't also launch the default player.
/// </summary>
private void TrackGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject source && FindAncestor<CheckBox>(source) != null) return;
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
}
/// <summary>
/// Walks up the visual tree from <paramref name="source"/> looking for an
/// ancestor (or the element itself) of type <typeparamref name="T"/>.
/// </summary>
private static T? FindAncestor<T>(DependencyObject source) where T : DependencyObject
{
var current = source;
while (current != null)
{
if (current is T typed) return typed;
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
}
return null;
}
private void OpenSelectedFileInDefaultPlayer()
{
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
try
{
Process.Start(new ProcessStartInfo(item.FilePath) { UseShellExecute = true });
}
catch (Exception ex)
{
StatusText.Text = $"Couldn't open {item.FileName}: {ex.Message}";
}
}
private void ClearQueueButton_Click(object sender, RoutedEventArgs e)
{
foreach (var item in _fileQueue)
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Clear();
UpdateLoadingOverlay();
}
/// <summary>
/// Scrolls the track grid or queue list by an amount proportional to the
/// wheel delta instead of relying on WPF's default per-event line
/// scrolling. Shared by both the file queue and track table so both
/// panes scroll consistently.
/// </summary>
private void SmoothList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
=> SmoothScrollHelper.HandlePreviewMouseWheel(sender, e);
private void FileQueueList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (FileQueueList.SelectedItem is QueuedFileItem file)
{
TrackGrid.ItemsSource = file.Tracks;
TrackGrid.Visibility = Visibility.Visible;
NoSelectionHint.Visibility = Visibility.Collapsed;
ExternalSubsList.ItemsSource = file.ExternalSubtitles;
ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
}
else
{
TrackGrid.ItemsSource = null;
TrackGrid.Visibility = Visibility.Collapsed;
NoSelectionHint.Visibility = Visibility.Visible;
ExternalSubsList.ItemsSource = null;
ExternalSubsPanel.Visibility = Visibility.Collapsed;
}
}
// ── Keyboard shortcuts ───────────────────────────────────────────
private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == System.Windows.Input.Key.R
&& Keyboard.Modifiers == ModifierKeys.Control
&& RunButton.IsEnabled)
{
RunButton_Click(RunButton, new RoutedEventArgs());
e.Handled = true;
}
}
// ── Run ─────────────────────────────────────────────────────────────────────
private void RunButton_Click(object sender, RoutedEventArgs e)
{
if (_isProcessing) return;
if (_fileQueue.Any(f => f.IsLoading)) return;
if (string.IsNullOrWhiteSpace(_settings.MkvMergePath) || !File.Exists(_settings.MkvMergePath))
{
StatusText.Text = "mkvmerge.exe not set — open Settings.";
return;
}
var readyFiles = _fileQueue
.Where(f => f.IsCorrect)
.ToList();
if (readyFiles.Count == 0)
{
StatusText.Text = _fileQueue.Count == 0
? "No files in queue."
: "No files ready (need exactly one audio + one subtitle selected).";
return;
}
SaveSettingsFromUi();
_isProcessing = true;
RunButton.IsEnabled = false;
StatusText.Text = string.Empty;
ProcessingOverlay.Visibility = Visibility.Visible;
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
{
Owner = this
};
progressWin.Closed += (_, _) =>
{
_isProcessing = false;
ProcessingOverlay.Visibility = Visibility.Collapsed;
if (progressWin.ShouldClearQueue)
{
foreach (var item in _fileQueue)
item.PropertyChanged -= QueuedFileItem_PropertyChanged;
_fileQueue.Clear();
}
UpdateLoadingOverlay();
};
progressWin.Show();
}
}