Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d7c14444 | ||
|
|
c7c125d40b | ||
|
|
304d9ad047 | ||
|
|
11a519d309 | ||
|
|
8b94fefe0f | ||
|
|
02ce7bb872 | ||
|
|
e61cb5e709 | ||
|
|
a322b6bb00 | ||
|
|
1909b7152a | ||
|
|
e7c5d4aca5 |
@@ -1,5 +1,6 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
publish/
|
||||||
*.user
|
*.user
|
||||||
history.json
|
history.json
|
||||||
settings.json
|
settings.json
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<AssemblyName>Futonizer</AssemblyName>
|
<AssemblyName>Futonizer</AssemblyName>
|
||||||
<RootNamespace>Futonizer</RootNamespace>
|
<RootNamespace>Futonizer</RootNamespace>
|
||||||
|
<Version>1.0.7</Version>
|
||||||
|
<FileVersion>1.0.7.0</FileVersion>
|
||||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
+74
-4
@@ -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 onto this window"
|
Text="Drop .mkv files (and subtitles) 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>
|
||||||
|
|
||||||
@@ -175,16 +176,25 @@
|
|||||||
Foreground="#F44336" VerticalAlignment="Center"
|
Foreground="#F44336" VerticalAlignment="Center"
|
||||||
Visibility="{Binding IsIncorrect, Converter={StaticResource BoolToVis}}"/>
|
Visibility="{Binding IsIncorrect, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Column="1" Text="{Binding FileName}"
|
<TextBlock Grid.Column="1" Text="{Binding DisplayFileName}"
|
||||||
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="×"
|
<Button Grid.Column="4" Content="×"
|
||||||
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="×" 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"
|
||||||
@@ -230,6 +289,7 @@
|
|||||||
GridLinesVisibility="None"
|
GridLinesVisibility="None"
|
||||||
VirtualizingPanel.ScrollUnit="Pixel"
|
VirtualizingPanel.ScrollUnit="Pixel"
|
||||||
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
|
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
|
||||||
|
PreviewKeyDown="TrackGrid_PreviewKeyDown"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Auto">
|
ScrollViewer.VerticalScrollBarVisibility="Auto">
|
||||||
<DataGrid.RowStyle>
|
<DataGrid.RowStyle>
|
||||||
@@ -328,8 +388,18 @@
|
|||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Processing overlay: dims the entire content area while stripping is running -->
|
||||||
|
<Border x:Name="ProcessingOverlay"
|
||||||
|
Grid.ColumnSpan="3"
|
||||||
|
Background="#99000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
IsHitTestVisible="True"
|
||||||
|
Panel.ZIndex="10"/>
|
||||||
|
|
||||||
<!-- Loading overlay: blocks interaction until every queued file has finished loading -->
|
<!-- Loading overlay: blocks interaction until every queued file has finished loading -->
|
||||||
<Border x:Name="LoadingOverlay"
|
<Border x:Name="LoadingOverlay"
|
||||||
Grid.ColumnSpan="3"
|
Grid.ColumnSpan="3"
|
||||||
|
|||||||
+164
-9
@@ -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;
|
||||||
@@ -131,6 +132,15 @@ public partial class MainWindow : FluentWindow
|
|||||||
RunButton.ToolTip = allCorrect
|
RunButton.ToolTip = allCorrect
|
||||||
? null
|
? null
|
||||||
: "Every file in the queue needs exactly one audio and one subtitle track selected before stripping.";
|
: "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)
|
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
@@ -187,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
|
||||||
{
|
{
|
||||||
@@ -199,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;
|
||||||
@@ -206,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 ─────────────────────────────────────────────────────────
|
||||||
@@ -403,6 +545,13 @@ public partial class MainWindow : FluentWindow
|
|||||||
/// Windows has associated with .mkv files (typically the default video
|
/// Windows has associated with .mkv files (typically the default video
|
||||||
/// player), so the user can quickly preview a file without leaving the app.
|
/// player), so the user can quickly preview a file without leaving the app.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
private void TrackGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != System.Windows.Input.Key.Space) return;
|
||||||
|
OpenSelectedFileInDefaultPlayer();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
private void OpenSelectedFileInDefaultPlayer()
|
private void OpenSelectedFileInDefaultPlayer()
|
||||||
{
|
{
|
||||||
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
|
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
|
||||||
@@ -441,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +646,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
RunButton.IsEnabled = false;
|
RunButton.IsEnabled = false;
|
||||||
StatusText.Text = string.Empty;
|
StatusText.Text = string.Empty;
|
||||||
|
ProcessingOverlay.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
|
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
|
||||||
{
|
{
|
||||||
@@ -501,6 +655,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
progressWin.Closed += (_, _) =>
|
progressWin.Closed += (_, _) =>
|
||||||
{
|
{
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
|
ProcessingOverlay.Visibility = Visibility.Collapsed;
|
||||||
if (progressWin.ShouldClearQueue)
|
if (progressWin.ShouldClearQueue)
|
||||||
{
|
{
|
||||||
foreach (var item in _fileQueue)
|
foreach (var item in _fileQueue)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using Futonizer.Services;
|
||||||
|
|
||||||
namespace Futonizer.Models;
|
namespace Futonizer.Models;
|
||||||
|
|
||||||
@@ -32,9 +33,29 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string FilePath { get; }
|
public string FilePath { get; }
|
||||||
public string FileName => Path.GetFileName(FilePath);
|
public string FileName => Path.GetFileName(FilePath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The filename as it will appear after rename adjustments (release-tag prefix
|
||||||
|
/// and/or show-name injection). Equals <see cref="FileName"/> when no
|
||||||
|
/// changes would be applied.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayFileName => FileNameHelper.ComputeOutputFileName(FilePath);
|
||||||
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;
|
||||||
@@ -63,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;
|
||||||
}
|
}
|
||||||
@@ -79,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)
|
||||||
@@ -104,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)
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Futonizer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure static helpers for computing the final output filename from a source
|
||||||
|
/// file path, applying release-tag prefixing and show-name injection.
|
||||||
|
/// </summary>
|
||||||
|
public static class FileNameHelper
|
||||||
|
{
|
||||||
|
// Folder suffix release tag: "Show Name-TTGA" → tag = "TTGA"
|
||||||
|
// All-uppercase, 2-8 chars, at end of folder name.
|
||||||
|
private static readonly Regex FolderSuffixTagRegex =
|
||||||
|
new(@"-([A-Z][A-Z0-9]{1,7})$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// File suffix release tag: "…HEVC-DemiHuman" → tag = "DemiHuman"
|
||||||
|
// Mixed case, 3-21 chars (no dots/spaces), at end of filename stem.
|
||||||
|
// Min 3 chars avoids false-positives like "-v2" or "-HD".
|
||||||
|
private static readonly Regex FileSuffixTagRegex =
|
||||||
|
new(@"-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Prefix release tag: "[KAA] Show Name" → tag = "KAA"
|
||||||
|
private static readonly Regex PrefixTagRegex =
|
||||||
|
new(@"^\[(.+?)\]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Episode-only filename (no tag prefix): starts with S01E01, S1E1, etc.
|
||||||
|
private static readonly Regex EpisodeOnlyRegex =
|
||||||
|
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Episode start after a tag prefix is stripped: plain number OR S01E01 style.
|
||||||
|
// e.g. "03 - Deus Vult ..." or "S01E01-Deep Blue"
|
||||||
|
private static readonly Regex EpisodeStartRegex =
|
||||||
|
new(@"^(\d+|S\d+E\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Individual metadata-token patterns used to find where the show name ends
|
||||||
|
// inside a folder name.
|
||||||
|
private static readonly Regex[] MetadataPatterns =
|
||||||
|
[
|
||||||
|
new(@"^S\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01
|
||||||
|
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01E01...
|
||||||
|
new(@"^E[Pp]?\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // E01, EP01
|
||||||
|
new(@"^\d{3,4}[pP]$", RegexOptions.Compiled), // 1080p, 720p
|
||||||
|
new(@"^(4K|UHD|2160p)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // 4K, UHD
|
||||||
|
new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources
|
||||||
|
new(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
|
||||||
|
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs
|
||||||
|
new(@"^(FLAC|AAC|DTS|TrueHD|Atmos|AC3|EAC3|Opus|MP3)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs
|
||||||
|
new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the final output filename for a given source file path, applying
|
||||||
|
/// (in order):
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Filename suffix tag extraction — a trailing <c>-GroupName</c> on
|
||||||
|
/// the filename stem is stripped and promoted to a bracket prefix.
|
||||||
|
/// Takes priority over any folder-derived tag.</item>
|
||||||
|
/// <item>Show-name injection — if the (now tag-free) filename starts with
|
||||||
|
/// an episode code (e.g. S01E01) the show name is derived from the
|
||||||
|
/// parent folder and prepended.</item>
|
||||||
|
/// <item>Release-tag prefix — a <c>[TAG]</c> bracket prefix, sourced from
|
||||||
|
/// the filename suffix, the folder suffix, or the folder prefix,
|
||||||
|
/// in that priority order.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Returns the original filename unchanged when no transformations apply.
|
||||||
|
/// </summary>
|
||||||
|
public static string ComputeOutputFileName(string sourceFilePath)
|
||||||
|
{
|
||||||
|
string fileName = Path.GetFileName(sourceFilePath);
|
||||||
|
string ext = Path.GetExtension(fileName);
|
||||||
|
string stemNoExt = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
string? folder = Path.GetDirectoryName(sourceFilePath);
|
||||||
|
string folderName = string.IsNullOrEmpty(folder) ? "" : (Path.GetFileName(folder) ?? "");
|
||||||
|
|
||||||
|
// 1. Extract in-filename suffix tag and remove it from the stem.
|
||||||
|
// e.g. "...HEVC-DemiHuman" → tag="DemiHuman", stem="...HEVC"
|
||||||
|
string? fileTag = null;
|
||||||
|
var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt);
|
||||||
|
if (fileSuffixMatch.Success)
|
||||||
|
{
|
||||||
|
fileTag = fileSuffixMatch.Groups[1].Value;
|
||||||
|
stemNoExt = stemNoExt[..fileSuffixMatch.Index];
|
||||||
|
fileName = stemNoExt + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Determine the release tag: filename suffix > folder tag.
|
||||||
|
string? tag = fileTag;
|
||||||
|
if (string.IsNullOrEmpty(tag) && !string.IsNullOrEmpty(folderName))
|
||||||
|
tag = ExtractReleaseTag(folderName);
|
||||||
|
|
||||||
|
// 3. Check for episode-only patterns and inject show name if needed.
|
||||||
|
if (!string.IsNullOrEmpty(folderName))
|
||||||
|
{
|
||||||
|
// Pattern A: untagged stem starts with S01E01 (e.g. "S01E01-Deep Blue.mkv")
|
||||||
|
if (EpisodeOnlyRegex.IsMatch(stemNoExt))
|
||||||
|
{
|
||||||
|
string showName = ExtractShowName(folderName);
|
||||||
|
if (!string.IsNullOrEmpty(showName))
|
||||||
|
fileName = showName + " " + fileName;
|
||||||
|
}
|
||||||
|
// Pattern B: file already carries [TAG] but episode number follows directly.
|
||||||
|
// e.g. "[WBDP] 03 - Deus Vult ..." → insert show name after the tag.
|
||||||
|
else if (!string.IsNullOrEmpty(tag))
|
||||||
|
{
|
||||||
|
string tagPfx = $"[{tag}] ";
|
||||||
|
if (stemNoExt.StartsWith(tagPfx, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string afterTag = stemNoExt[tagPfx.Length..];
|
||||||
|
if (EpisodeStartRegex.IsMatch(afterTag))
|
||||||
|
{
|
||||||
|
string showName = ExtractShowName(folderName);
|
||||||
|
if (!string.IsNullOrEmpty(showName))
|
||||||
|
{
|
||||||
|
// Strip the tag, inject show name; tag is re-prepended in step 4.
|
||||||
|
fileName = showName + " " + fileName[tagPfx.Length..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Prepend release tag if not already present.
|
||||||
|
if (!string.IsNullOrEmpty(tag))
|
||||||
|
{
|
||||||
|
string tagPrefix = $"[{tag}]";
|
||||||
|
if (!fileName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
fileName = tagPrefix + " " + fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the release tag string (without brackets) from a folder name.
|
||||||
|
/// Suffix pattern (<c>-TAG</c>) takes priority over prefix pattern
|
||||||
|
/// (<c>[TAG]</c>). Returns <c>null</c> when neither is present.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ExtractReleaseTag(string folderName)
|
||||||
|
{
|
||||||
|
var suffixMatch = FolderSuffixTagRegex.Match(folderName);
|
||||||
|
if (suffixMatch.Success) return suffixMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var prefixMatch = PrefixTagRegex.Match(folderName);
|
||||||
|
if (prefixMatch.Success) return prefixMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the show name from a folder name by stripping any release tag
|
||||||
|
/// (prefix or suffix) and then:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>If the remainder contains brackets (e.g. <c>[BD][1080p-FLAC]</c>),
|
||||||
|
/// returns the text that precedes the first bracket.</item>
|
||||||
|
/// <item>Otherwise splits on spaces and collects words up to the first
|
||||||
|
/// recognised metadata token (season, quality, source, codec, etc.).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static string ExtractShowName(string folderName)
|
||||||
|
{
|
||||||
|
// Strip release tags first.
|
||||||
|
string name = FolderSuffixTagRegex.Replace(folderName, "").Trim();
|
||||||
|
name = PrefixTagRegex.Replace(name, "").Trim();
|
||||||
|
|
||||||
|
// Nothing left, or what remains immediately starts with a bracket — no show name.
|
||||||
|
if (string.IsNullOrEmpty(name) || name[0] == '[') return string.Empty;
|
||||||
|
|
||||||
|
// Bracket-style metadata: "The Saga of Tanya the Evil [BD][1080p-FLAC][HEVC]"
|
||||||
|
int bracketIdx = name.IndexOf('[');
|
||||||
|
if (bracketIdx > 0)
|
||||||
|
return name[..bracketIdx].Trim();
|
||||||
|
|
||||||
|
// Space-separated metadata: "Grand Blue Dreaming S01 1080p BD Remux FLAC"
|
||||||
|
var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var showWords = new List<string>();
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
if (IsMetadataToken(word)) break;
|
||||||
|
showWords.Add(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" ", showWords).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMetadataToken(string token)
|
||||||
|
{
|
||||||
|
foreach (var pattern in MetadataPatterns)
|
||||||
|
if (pattern.IsMatch(token)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-8
@@ -93,9 +93,12 @@ public class MkvService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
|
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
|
||||||
/// tracks are always pre-checked. Exactly one audio track is pre-checked:
|
/// tracks are always pre-checked. Exactly one audio track is always
|
||||||
/// among Japanese/Chinese/undefined-language tracks, a stereo (2-channel)
|
/// pre-checked: among Japanese/Chinese/undefined-language tracks, a
|
||||||
/// track is preferred, otherwise the first matching track in file order.
|
/// stereo (2-channel) track is preferred, otherwise the first matching
|
||||||
|
/// track in file order. If no such track exists (e.g. only English
|
||||||
|
/// audio), the very first audio track in the file is pre-checked instead,
|
||||||
|
/// so a file is never left with no audio track selected.
|
||||||
/// Subtitle tracks are never pre-checked — the user picks one via the
|
/// Subtitle tracks are never pre-checked — the user picks one via the
|
||||||
/// track table, and the choice propagates to other queued files.
|
/// track table, and the choice propagates to other queued files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -108,7 +111,8 @@ public class MkvService
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
|
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
|
||||||
?? eligibleAudio.FirstOrDefault();
|
?? eligibleAudio.FirstOrDefault()
|
||||||
|
?? audioTracks.FirstOrDefault();
|
||||||
|
|
||||||
var items = info.Tracks.Select(t =>
|
var items = info.Tracks.Select(t =>
|
||||||
{
|
{
|
||||||
@@ -258,12 +262,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 +286,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 +392,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 +429,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,7 +144,8 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
{
|
{
|
||||||
await semaphore.WaitAsync(ct);
|
await semaphore.WaitAsync(ct);
|
||||||
|
|
||||||
var job = new JobProgressItem { FileName = file.FileName, FileSizeBytes = SafeFileLength(file.FilePath) };
|
string computedFileName = FileNameHelper.ComputeOutputFileName(file.FilePath);
|
||||||
|
var job = new JobProgressItem { FileName = computedFileName, FileSizeBytes = SafeFileLength(file.FilePath) };
|
||||||
job.PropertyChanged += (_, args) =>
|
job.PropertyChanged += (_, args) =>
|
||||||
{
|
{
|
||||||
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
|
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
|
||||||
@@ -157,9 +158,11 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
string sourceDir = Path.GetDirectoryName(file.FilePath) ?? "";
|
||||||
|
|
||||||
string outputFilePath = overwritingInPlace
|
string outputFilePath = overwritingInPlace
|
||||||
? file.FilePath
|
? Path.Combine(sourceDir, computedFileName)
|
||||||
: Path.Combine(_outputFolder, file.FileName);
|
: Path.Combine(_outputFolder, computedFileName);
|
||||||
|
|
||||||
var videoIds = file.Tracks
|
var videoIds = file.Tracks
|
||||||
.Where(t => t.Type == "video" && t.Copy)
|
.Where(t => t.Type == "video" && t.Copy)
|
||||||
@@ -171,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 =>
|
||||||
{
|
{
|
||||||
@@ -184,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} {result.FileName} — {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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user