Match and mux in dropped external subtitles

Drag in subtitle files alongside .mkv files and they're matched to
the right video by episode code (S04E06, zero-padding and separator
insensitive, plus 4x06 as a fallback) and muxed in as extra subtitle
tracks. Any subtitle format mkvmerge accepts is recognized: SRT,
(S)SA, VobSub, WebVTT, PGS/SUP, USF, SAMI, TTML/DFXP, STL, Kate.

Matching works regardless of drop order — subtitles dropped before
their video are held pending until a matching video shows up, and
vice versa. Language, forced, and SDH flags are guessed from common
filename tokens (e.g. "eng", "forced", "sdh") to set the muxed
track's language/name/flags; unmatched subtitles are reported in the
status bar.
This commit is contained in:
2026-07-08 10:37:56 +02:00
parent 304d9ad047
commit c7c125d40b
9 changed files with 511 additions and 21 deletions
+146 -9
View File
@@ -20,6 +20,7 @@ public partial class MainWindow : FluentWindow
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;
@@ -196,10 +197,9 @@ public partial class MainWindow : FluentWindow
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasMkv = paths.Any(p =>
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
&& File.Exists(p));
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
bool hasAcceptedFile = paths.Any(p =>
File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
}
else
{
@@ -208,6 +208,9 @@ public partial class MainWindow : FluentWindow
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;
@@ -215,28 +218,158 @@ public partial class MainWindow : FluentWindow
Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
var newFiles = paths
.Where(p => string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
var newVideoPaths = paths
.Where(p => IsMkvFile(p)
&& File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList();
foreach (var path in newFiles)
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 (newFiles.Count > 0)
if (newItems.Count > 0)
{
var first = _fileQueue.FirstOrDefault(q =>
string.Equals(q.FilePath, newFiles[0], StringComparison.OrdinalIgnoreCase));
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 code (S04E06 style)."
: $"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 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.
/// </summary>
private bool MatchSubtitleToQueue(string subtitlePath)
{
string subFileName = Path.GetFileName(subtitlePath);
string? code = SubtitleMatcher.ExtractEpisodeCode(subFileName);
List<QueuedFileItem> targets;
if (code != null)
{
targets = _fileQueue
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), code, StringComparison.OrdinalIgnoreCase))
.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 sharing this video's
/// episode code, and attaches + removes any matches from the pending list.
/// </summary>
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
{
if (_pendingSubtitles.Count == 0) return;
string? code = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
if (code == null) return;
var matches = _pendingSubtitles
.Where(p => string.Equals(SubtitleMatcher.ExtractEpisodeCode(Path.GetFileName(p)), code, StringComparison.OrdinalIgnoreCase))
.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 ─────────────────────────────────────────────────────────
@@ -457,12 +590,16 @@ public partial class MainWindow : FluentWindow
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;
}
}