1 Commits
Author SHA1 Message Date
l4kr c7c125d40b 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.
2026-07-08 10:37:56 +02:00
9 changed files with 511 additions and 21 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName> <AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace> <RootNamespace>Futonizer</RootNamespace>
<Version>1.0.5</Version> <Version>1.0.6</Version>
<FileVersion>1.0.5.0</FileVersion> <FileVersion>1.0.6.0</FileVersion>
<ApplicationIcon>Assets\app.ico</ApplicationIcon> <ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
+63 -3
View File
@@ -137,7 +137,7 @@
<Grid Grid.Row="1"> <Grid Grid.Row="1">
<TextBlock x:Name="DropHint" <TextBlock x:Name="DropHint"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Drop .mkv files&#10;onto this window" Text="Drop .mkv files (and subtitles)&#10;onto this window"
Opacity="0.4" FontSize="14" TextAlignment="Center" Opacity="0.4" FontSize="14" TextAlignment="Center"
IsHitTestVisible="False"/> IsHitTestVisible="False"/>
@@ -158,6 +158,7 @@
<ColumnDefinition Width="16"/> <ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="18"/> <ColumnDefinition Width="18"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@@ -179,12 +180,21 @@
FontSize="12" VerticalAlignment="Center" Margin="6,0" FontSize="12" VerticalAlignment="Center" Margin="6,0"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="2" Text="{Binding FileSizeDisplay}" <Border Grid.Column="2" CornerRadius="8" Padding="5,1" Margin="2,0"
VerticalAlignment="Center"
Background="{DynamicResource AccentFillColorSecondaryBrush}"
Visibility="{Binding HasExternalSubtitles, Converter={StaticResource BoolToVis}}"
ToolTip="{Binding ExternalSubtitlesTooltip}">
<TextBlock Text="{Binding ExternalSubtitles.Count, StringFormat={}+{0} sub}"
FontSize="9" FontWeight="SemiBold"/>
</Border>
<TextBlock Grid.Column="3" Text="{Binding FileSizeDisplay}"
FontSize="11" VerticalAlignment="Center" Margin="4,0" FontSize="11" VerticalAlignment="Center" Margin="4,0"
Opacity="0.6" Opacity="0.6"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/> Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
<Button Grid.Column="3" Content="&#xD7;" <Button Grid.Column="4" Content="&#xD7;"
Tag="{Binding}" Tag="{Binding}"
Click="RemoveFile_Click" Click="RemoveFile_Click"
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
@@ -209,6 +219,55 @@
BorderBrush="{DynamicResource ControlElevationBorderBrush}" BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1"> BorderThickness="1">
<Grid> <Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- External subtitles matched to the selected file -->
<Border x:Name="ExternalSubsPanel" Grid.Row="0" Margin="10,8,10,0"
Padding="8,6" CornerRadius="6" Visibility="Collapsed"
Background="{DynamicResource ControlFillColorSecondaryBrush}">
<StackPanel>
<TextBlock Text="External subtitles to mux in" FontSize="11"
FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
<ItemsControl x:Name="ExternalSubsList">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1" CornerRadius="4"
Margin="0,0,6,6" Padding="6,3">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding LanguageDisplay}" FontSize="11"
FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding FileName}" FontSize="11" Opacity="0.6"
Margin="6,0,0,0" VerticalAlignment="Center"
MaxWidth="220" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="DEFAULT" FontSize="9" FontWeight="Bold"
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
Margin="6,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsDefault, Converter={StaticResource BoolToVis}}"/>
<Button Content="&#xD7;" Tag="{Binding}"
Click="RemoveExternalSubtitle_Click"
Background="Transparent" BorderThickness="0"
Padding="6,0,0,0" Cursor="Hand" FontSize="13"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Grid Grid.Row="1">
<TextBlock x:Name="NoSelectionHint" <TextBlock x:Name="NoSelectionHint"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Select a file to view its tracks" Text="Select a file to view its tracks"
@@ -328,6 +387,7 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</Grid>
</Grid> </Grid>
</Border> </Border>
+146 -9
View File
@@ -20,6 +20,7 @@ public partial class MainWindow : FluentWindow
private AppSettings _settings = new(); private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new(); private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly List<string> _pendingSubtitles = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans); private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing; private bool _isProcessing;
private bool _propagatingSelection; private bool _propagatingSelection;
@@ -196,10 +197,9 @@ public partial class MainWindow : FluentWindow
if (e.Data.GetDataPresent(DataFormats.FileDrop)) if (e.Data.GetDataPresent(DataFormats.FileDrop))
{ {
var paths = (string[])e.Data.GetData(DataFormats.FileDrop); var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasMkv = paths.Any(p => bool hasAcceptedFile = paths.Any(p =>
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase) File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
&& File.Exists(p)); e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
} }
else else
{ {
@@ -208,6 +208,9 @@ public partial class MainWindow : FluentWindow
e.Handled = true; 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) private void Window_Drop(object sender, DragEventArgs e)
{ {
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
@@ -215,28 +218,158 @@ public partial class MainWindow : FluentWindow
Activate(); Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop); 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) && File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase))) && !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList(); .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); var item = new QueuedFileItem(path);
item.PropertyChanged += QueuedFileItem_PropertyChanged; item.PropertyChanged += QueuedFileItem_PropertyChanged;
_fileQueue.Add(item); _fileQueue.Add(item);
newItems.Add(item);
_ = ScanFileItemAsync(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(); UpdateLoadingOverlay();
if (newFiles.Count > 0) if (newItems.Count > 0)
{ {
var first = _fileQueue.FirstOrDefault(q => 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; 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 ───────────────────────────────────────────────────────── // ── File scanning ─────────────────────────────────────────────────────────
@@ -457,12 +590,16 @@ public partial class MainWindow : FluentWindow
TrackGrid.ItemsSource = file.Tracks; TrackGrid.ItemsSource = file.Tracks;
TrackGrid.Visibility = Visibility.Visible; TrackGrid.Visibility = Visibility.Visible;
NoSelectionHint.Visibility = Visibility.Collapsed; NoSelectionHint.Visibility = Visibility.Collapsed;
ExternalSubsList.ItemsSource = file.ExternalSubtitles;
ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
} }
else else
{ {
TrackGrid.ItemsSource = null; TrackGrid.ItemsSource = null;
TrackGrid.Visibility = Visibility.Collapsed; TrackGrid.Visibility = Visibility.Collapsed;
NoSelectionHint.Visibility = Visibility.Visible; NoSelectionHint.Visibility = Visibility.Visible;
ExternalSubsList.ItemsSource = null;
ExternalSubsPanel.Visibility = Visibility.Collapsed;
} }
} }
+58
View File
@@ -0,0 +1,58 @@
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// Represents one external subtitle file that was drag-and-dropped in and
/// matched to a <see cref="QueuedFileItem"/> by episode code (e.g. S04E06),
/// to be muxed in as an additional subtitle track when the file is processed.
/// </summary>
public class ExternalSubtitleTrack : INotifyPropertyChanged
{
private bool _isDefault;
public string FilePath { get; }
public string FileName => Path.GetFileName(FilePath);
/// <summary>Uppercased extension without the dot, e.g. "SRT", "ASS", "SUP".</summary>
public string FormatDisplay => Path.GetExtension(FilePath).TrimStart('.').ToUpperInvariant();
/// <summary>ISO 639-2 language code detected from the filename, or "und" if unknown.</summary>
public string Language { get; init; } = "und";
/// <summary>Track name to embed in the output file; may be empty.</summary>
public string TrackName { get; init; } = string.Empty;
public bool Forced { get; init; }
public bool HearingImpaired { get; init; }
/// <summary>
/// Whether this is the subtitle track flagged default in the output.
/// Exactly one external subtitle per file should have this set at a time.
/// </summary>
public bool IsDefault
{
get => _isDefault;
set
{
if (_isDefault == value) return;
_isDefault = value;
OnPropertyChanged();
}
}
/// <summary>Short "FORMAT · LANG" label shown as a chip in the UI.</summary>
public string LanguageDisplay => $"{FormatDisplay} · {(string.Equals(Language, "und", StringComparison.OrdinalIgnoreCase) ? "UND" : Language.ToUpperInvariant())}";
public ExternalSubtitleTrack(string filePath)
{
FilePath = filePath;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+27 -1
View File
@@ -43,6 +43,19 @@ public class QueuedFileItem : INotifyPropertyChanged
public string FileSizeDisplay { get; } public string FileSizeDisplay { get; }
public ObservableCollection<TrackItem> Tracks { get; } = new(); public ObservableCollection<TrackItem> Tracks { get; } = new();
/// <summary>
/// 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.
/// </summary>
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
public bool HasExternalSubtitles => ExternalSubtitles.Count > 0;
public string ExternalSubtitlesTooltip => ExternalSubtitles.Count == 0
? string.Empty
: "External subtitles to mux in:\n" + string.Join("\n", ExternalSubtitles.Select(s => $"- {s.FileName} ({s.LanguageDisplay})"));
public string Status public string Status
{ {
get => _status; get => _status;
@@ -71,7 +84,12 @@ public class QueuedFileItem : INotifyPropertyChanged
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy); int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy); int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy);
return audioSelected == 1 && subtitleSelected == 1 // A subtitle requirement is met either by picking exactly one
// internal track, or by having at least one matched external
// subtitle file (which can also be combined with one internal track).
bool subtitleOk = subtitleSelected == 1 || (subtitleSelected == 0 && ExternalSubtitles.Count > 0);
return audioSelected == 1 && subtitleOk
? FileLoadState.Correct ? FileLoadState.Correct
: FileLoadState.Incorrect; : FileLoadState.Incorrect;
} }
@@ -87,6 +105,7 @@ public class QueuedFileItem : INotifyPropertyChanged
FilePath = filePath; FilePath = filePath;
FileSizeDisplay = FormatFileSize(filePath); FileSizeDisplay = FormatFileSize(filePath);
Tracks.CollectionChanged += OnTracksChanged; Tracks.CollectionChanged += OnTracksChanged;
ExternalSubtitles.CollectionChanged += OnExternalSubtitlesChanged;
} }
private static string FormatFileSize(string filePath) private static string FormatFileSize(string filePath)
@@ -112,6 +131,13 @@ public class QueuedFileItem : INotifyPropertyChanged
} }
} }
private void OnExternalSubtitlesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(HasExternalSubtitles));
OnPropertyChanged(nameof(ExternalSubtitlesTooltip));
NotifyLoadState();
}
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e) private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
if (e.NewItems != null) if (e.NewItems != null)
+43 -4
View File
@@ -258,12 +258,13 @@ public class MkvService
IReadOnlyList<int> videoKeepIds, IReadOnlyList<int> videoKeepIds,
IReadOnlyList<int> audioKeepIds, IReadOnlyList<int> audioKeepIds,
IReadOnlyList<int> subtitleKeepIds, IReadOnlyList<int> subtitleKeepIds,
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles = null,
IProgress<ProcessingProgress>? progress = null, IProgress<ProcessingProgress>? progress = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return StripTracksInternalAsync( return StripTracksInternalAsync(
filePath, Path.GetFileName(filePath), outputFilePath, filePath, Path.GetFileName(filePath), outputFilePath,
videoKeepIds, audioKeepIds, subtitleKeepIds, videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
progress, ct); progress, ct);
} }
@@ -281,6 +282,7 @@ public class MkvService
null, // null = keep all video null, // null = keep all video
scan.AudioKeepIds, scan.AudioKeepIds,
scan.SubtitleKeepIds, scan.SubtitleKeepIds,
null,
progress, ct); progress, ct);
} }
@@ -386,6 +388,7 @@ public class MkvService
IReadOnlyList<int>? videoKeepIds, IReadOnlyList<int>? videoKeepIds,
IReadOnlyList<int>? audioKeepIds, IReadOnlyList<int>? audioKeepIds,
IReadOnlyList<int>? subtitleKeepIds, IReadOnlyList<int>? subtitleKeepIds,
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles,
IProgress<ProcessingProgress>? progress, IProgress<ProcessingProgress>? progress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -422,16 +425,52 @@ public class MkvService
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds); AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds); AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
// The one subtitle track the user chose to keep is always flagged // Only one subtitle track in the whole output should ever be
// as the default subtitle track in the output. // flagged default. If any external subtitle is marked default,
// it takes priority over the kept internal subtitle track.
bool hasExternalDefault = externalSubtitles != null && externalSubtitles.Any(s => s.IsDefault);
if (subtitleKeepIds != null && subtitleKeepIds.Count == 1) if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
{ {
psi.ArgumentList.Add("--default-track-flag"); psi.ArgumentList.Add("--default-track-flag");
psi.ArgumentList.Add($"{subtitleKeepIds[0]}:yes"); psi.ArgumentList.Add(hasExternalDefault ? $"{subtitleKeepIds[0]}:no" : $"{subtitleKeepIds[0]}:yes");
} }
psi.ArgumentList.Add(filePath); psi.ArgumentList.Add(filePath);
// Append each matched external subtitle file as its own input,
// with per-file options (language/name/flags) scoped to it.
if (externalSubtitles != null)
{
foreach (var sub in externalSubtitles)
{
if (!string.IsNullOrEmpty(sub.Language) && !string.Equals(sub.Language, "und", StringComparison.OrdinalIgnoreCase))
{
psi.ArgumentList.Add("--language");
psi.ArgumentList.Add($"0:{sub.Language}");
}
if (!string.IsNullOrWhiteSpace(sub.TrackName))
{
psi.ArgumentList.Add("--track-name");
psi.ArgumentList.Add($"0:{sub.TrackName}");
}
if (sub.Forced)
{
psi.ArgumentList.Add("--forced-display-flag");
psi.ArgumentList.Add("0:yes");
}
if (sub.HearingImpaired)
{
psi.ArgumentList.Add("--hearing-impaired-flag");
psi.ArgumentList.Add("0:yes");
}
psi.ArgumentList.Add("--default-track-flag");
psi.ArgumentList.Add(sub.IsDefault ? "0:yes" : "0:no");
psi.ArgumentList.Add(sub.FilePath);
}
}
using var process = new Process { StartInfo = psi }; using var process = new Process { StartInfo = psi };
process.Start(); process.Start();
+105
View File
@@ -0,0 +1,105 @@
using System.IO;
namespace Futonizer.Services;
/// <summary>
/// Result of scanning an external subtitle's filename for language and
/// display hints.
/// </summary>
public readonly record struct SubtitleLanguageInfo(string Language, bool Forced, bool HearingImpaired, string TrackName);
/// <summary>
/// Best-effort language/flag detection for external subtitle files, based on
/// common filename tokens (e.g. "Show.S04E06.eng.srt", "Show.S04E06.forced.srt").
/// Never throws — falls back to an "und" (undetermined) language with no
/// track name when nothing recognisable is found.
/// </summary>
public static class SubtitleLanguageHelper
{
private static readonly Dictionary<string, string> TokenToIso6392 = new(StringComparer.OrdinalIgnoreCase)
{
["en"] = "eng", ["eng"] = "eng", ["english"] = "eng",
["ja"] = "jpn", ["jp"] = "jpn", ["jpn"] = "jpn", ["japanese"] = "jpn",
["es"] = "spa", ["spa"] = "spa", ["spanish"] = "spa", ["esp"] = "spa",
["fr"] = "fre", ["fre"] = "fre", ["fra"] = "fre", ["french"] = "fre",
["de"] = "ger", ["ger"] = "ger", ["deu"] = "ger", ["german"] = "ger",
["it"] = "ita", ["ita"] = "ita", ["italian"] = "ita",
["pt"] = "por", ["por"] = "por", ["portuguese"] = "por", ["ptbr"] = "por",
["ru"] = "rus", ["rus"] = "rus", ["russian"] = "rus",
["zh"] = "chi", ["chi"] = "chi", ["zho"] = "chi", ["chinese"] = "chi", ["cmn"] = "chi", ["yue"] = "chi",
["ko"] = "kor", ["kor"] = "kor", ["korean"] = "kor",
["ar"] = "ara", ["ara"] = "ara", ["arabic"] = "ara",
["nl"] = "dut", ["dut"] = "dut", ["nld"] = "dut", ["dutch"] = "dut",
["sv"] = "swe", ["swe"] = "swe", ["swedish"] = "swe",
["no"] = "nor", ["nor"] = "nor", ["norwegian"] = "nor",
["da"] = "dan", ["dan"] = "dan", ["danish"] = "dan",
["fi"] = "fin", ["fin"] = "fin", ["finnish"] = "fin",
["pl"] = "pol", ["pol"] = "pol", ["polish"] = "pol",
["tr"] = "tur", ["tur"] = "tur", ["turkish"] = "tur",
["vi"] = "vie", ["vie"] = "vie", ["vietnamese"] = "vie",
["th"] = "tha", ["tha"] = "tha", ["thai"] = "tha",
["id"] = "ind", ["ind"] = "ind", ["indonesian"] = "ind",
["he"] = "heb", ["heb"] = "heb", ["hebrew"] = "heb",
["hin"] = "hin", ["hindi"] = "hin",
["cs"] = "cze", ["cze"] = "cze", ["ces"] = "cze", ["czech"] = "cze",
["el"] = "gre", ["gre"] = "gre", ["ell"] = "gre", ["greek"] = "gre",
["hu"] = "hun", ["hun"] = "hun", ["hungarian"] = "hun",
["ro"] = "rum", ["rum"] = "rum", ["ron"] = "rum", ["romanian"] = "rum",
["uk"] = "ukr", ["ukr"] = "ukr", ["ukrainian"] = "ukr",
};
private static readonly Dictionary<string, string> Iso6392ToDisplayName = new(StringComparer.OrdinalIgnoreCase)
{
["eng"] = "English", ["jpn"] = "Japanese", ["spa"] = "Spanish", ["fre"] = "French",
["ger"] = "German", ["ita"] = "Italian", ["por"] = "Portuguese", ["rus"] = "Russian",
["chi"] = "Chinese", ["kor"] = "Korean", ["ara"] = "Arabic", ["dut"] = "Dutch",
["swe"] = "Swedish", ["nor"] = "Norwegian", ["dan"] = "Danish", ["fin"] = "Finnish",
["pol"] = "Polish", ["tur"] = "Turkish", ["vie"] = "Vietnamese", ["tha"] = "Thai",
["ind"] = "Indonesian", ["heb"] = "Hebrew", ["hin"] = "Hindi", ["cze"] = "Czech",
["gre"] = "Greek", ["hun"] = "Hungarian", ["rum"] = "Romanian", ["ukr"] = "Ukrainian",
};
private static readonly char[] TokenSeparators = { '.', '_', '-', ' ', '(', ')', '[', ']' };
public static SubtitleLanguageInfo Detect(string fileName)
{
string stem = Path.GetFileNameWithoutExtension(fileName);
var tokens = stem.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries);
string language = "und";
bool forced = false;
bool hearingImpaired = false;
foreach (var token in tokens)
{
if (string.Equals(token, "forced", StringComparison.OrdinalIgnoreCase))
{
forced = true;
continue;
}
if (string.Equals(token, "sdh", StringComparison.OrdinalIgnoreCase)
|| string.Equals(token, "cc", StringComparison.OrdinalIgnoreCase))
{
hearingImpaired = true;
continue;
}
if (language == "und" && TokenToIso6392.TryGetValue(token, out var iso))
language = iso;
}
string trackName = string.Empty;
if (Iso6392ToDisplayName.TryGetValue(language, out var displayName))
trackName = displayName;
if (forced || hearingImpaired)
{
var suffixes = new List<string>();
if (forced) suffixes.Add("Forced");
if (hearingImpaired) suffixes.Add("SDH");
string suffix = $" ({string.Join(", ", suffixes)})";
trackName = string.IsNullOrEmpty(trackName) ? suffix.Trim(' ', '(', ')') : trackName + suffix;
}
return new SubtitleLanguageInfo(language, forced, hearingImpaired, trackName);
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.IO;
using System.Text.RegularExpressions;
namespace Futonizer.Services;
/// <summary>
/// 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.
/// </summary>
public static class SubtitleMatcher
{
/// <summary>
/// Every subtitle container/format extension mkvmerge (and therefore
/// Futonizer) can mux in: SubRip, (Advanced) SubStation Alpha, VobSub,
/// WebVTT, HDMV/PGS, Universal Subtitle Format, SAMI, TTML/DFXP, EBU-STL,
/// MicroDVD/SubViewer (both commonly saved with a plain ".sub" extension),
/// and Kate.
/// </summary>
public static readonly string[] SupportedExtensions =
{
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", ".webvtt",
".sup", ".pgs", ".usf", ".smi", ".sami", ".ttml", ".dfxp",
".stl", ".kate",
};
public static bool IsSubtitleFile(string path)
{
string ext = Path.GetExtension(path);
return !string.IsNullOrEmpty(ext)
&& SupportedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
}
// "S04E06", "S4E6", "S04 E06", "S04-E06", "S04.E06" — the standard scene/anime tagging scheme.
private static readonly Regex SeasonEpisodeRegex =
new(@"S(\d{1,4})[\s._-]*E(\d{1,4})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Fallback: "4x06" style season x episode separator.
private static readonly Regex SeasonXEpisodeRegex =
new(@"(?<![\d.])(\d{1,2})x(\d{2,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// 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.
/// </summary>
public static string? ExtractEpisodeCode(string fileName)
{
var match = SeasonEpisodeRegex.Match(fileName);
if (!match.Success)
match = SeasonXEpisodeRegex.Match(fileName);
if (!match.Success) return null;
if (!int.TryParse(match.Groups[1].Value, out int season)) return null;
if (!int.TryParse(match.Groups[2].Value, out int episode)) return null;
return $"S{season:D2}E{episode:D2}";
}
}
+6 -2
View File
@@ -174,7 +174,7 @@ public partial class ProgressWindow : FluentWindow
.Where(t => t.Type == "subtitles" && t.Copy) .Where(t => t.Type == "subtitles" && t.Copy)
.Select(t => t.Id).ToList(); .Select(t => t.Id).ToList();
bool nothingToStrip = file.Tracks.All(t => t.Copy); bool nothingToStrip = file.Tracks.All(t => t.Copy) && file.ExternalSubtitles.Count == 0;
var progress = new Progress<ProcessingProgress>(p => var progress = new Progress<ProcessingProgress>(p =>
{ {
@@ -187,10 +187,14 @@ public partial class ProgressWindow : FluentWindow
: await service.StripTracksAsync( : await service.StripTracksAsync(
file.FilePath, outputFilePath, file.FilePath, outputFilePath,
videoIds, audioIds, subIds, videoIds, audioIds, subIds,
file.ExternalSubtitles.ToList(),
progress, ct); progress, ct);
string tag = result.Success ? "[DONE]" : "[FAIL]"; string tag = result.Success ? "[DONE]" : "[FAIL]";
AppendLog($"{tag} {Path.GetFileName(result.OutputFilePath)} — {result.Message}"); string extInfo = file.ExternalSubtitles.Count > 0
? $" (+{file.ExternalSubtitles.Count} external sub{(file.ExternalSubtitles.Count == 1 ? "" : "s")})"
: string.Empty;
AppendLog($"{tag} {Path.GetFileName(result.OutputFilePath)}{extInfo} — {result.Message}");
HistoryService.Add(new HistoryEntry HistoryService.Add(new HistoryEntry
{ {