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:
+146
-9
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user