Initial commit: Futonizer MKV track stripper

This commit is contained in:
l4kr
2026-07-04 00:43:37 +02:00
commit 1208cadc3b
25 changed files with 2629 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
<ui:FluentWindow x:Class="Futonizer.Views.HistoryWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="History"
Height="520" Width="640"
MinHeight="360" MinWidth="480"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style x:Key="HistoryListBoxItemStyle" TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Margin" Value="3,1"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder"
Background="Transparent"
CornerRadius="6"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<Grid>
<Border x:Name="AccentBar"
Width="3" HorizontalAlignment="Left"
Margin="-10,-6,0,-6"
CornerRadius="2"
Background="{DynamicResource AccentFillColorDefaultBrush}"
Visibility="Collapsed"/>
<ContentPresenter/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder" Property="Background"
Value="{DynamicResource ControlFillColorSecondaryBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBorder" Property="Background"
Value="{DynamicResource AccentFillColorSecondaryBrush}"/>
<Setter TargetName="AccentBar" Property="Visibility" Value="Visible"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="History"/>
<Grid Grid.Row="1" Margin="16,4,16,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" x:Name="SummaryText"
FontSize="12" Opacity="0.7" Margin="4,0,0,8"/>
<Border Grid.Row="1" CornerRadius="8" ClipToBounds="True"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1">
<Grid>
<TextBlock x:Name="EmptyHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="No history yet"
Opacity="0.4" FontSize="14"
IsHitTestVisible="False"/>
<ListBox x:Name="HistoryList"
Background="Transparent" BorderThickness="0"
HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="HistoryList_PreviewMouseWheel"
ItemContainerStyle="{StaticResource HistoryListBoxItemStyle}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ToolTip="{Binding Message}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="&#x2713;" FontSize="12"
Foreground="#4CAF50" VerticalAlignment="Center"
Visibility="{Binding Success, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="0" Text="&#x2717;" FontSize="12"
Foreground="#F44336" VerticalAlignment="Center"
Visibility="{Binding Failed, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="1" Text="{Binding TimestampDisplay}"
FontSize="11" Opacity="0.7" VerticalAlignment="Center" Margin="8,0"/>
<TextBlock Grid.Column="2" Text="{Binding FileName}"
FontSize="12" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>
</Grid>
</ui:FluentWindow>
+27
View File
@@ -0,0 +1,27 @@
using System.Windows.Input;
using Futonizer.Services;
using Wpf.Ui.Controls;
namespace Futonizer.Views;
public partial class HistoryWindow : FluentWindow
{
public HistoryWindow()
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
var entries = HistoryService.Load();
HistoryList.ItemsSource = entries;
EmptyHint.Visibility = entries.Count == 0
? System.Windows.Visibility.Visible
: System.Windows.Visibility.Collapsed;
SummaryText.Text = entries.Count == 0
? string.Empty
: $"{entries.Count} entr{(entries.Count == 1 ? "y" : "ies")} (newest first, capped at 1000)";
}
private void HistoryList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
=> SmoothScrollHelper.HandlePreviewMouseWheel(sender, e);
}
+71
View File
@@ -0,0 +1,71 @@
<ui:FluentWindow x:Class="Futonizer.Views.ProgressWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="Stripping Tracks"
Height="500" Width="720"
MinHeight="380" MinWidth="500"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round"
Closing="Window_Closing"
Loaded="Window_Loaded">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="Stripping Tracks"/>
<Grid Grid.Row="1" Margin="16,8,16,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Active jobs -->
<ItemsControl x:Name="ActiveJobsList" Grid.Row="0" Margin="0,0,0,10">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ProgressBar Grid.Row="0" Height="6" Minimum="0" Maximum="100" Value="{Binding Percent}"/>
<TextBlock Grid.Row="1" Margin="0,2,0,0" FontSize="11" Opacity="0.75" Text="{Binding Display}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Log -->
<Border Grid.Row="1" CornerRadius="8"
Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}" BorderThickness="1">
<ui:TextBox x:Name="LogBox" IsReadOnly="True" TextWrapping="NoWrap"
AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
BorderThickness="0" Background="Transparent"/>
</Border>
<!-- Status + speed + button -->
<Grid Grid.Row="2" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="StatusText" Grid.Column="0" VerticalAlignment="Center" FontWeight="SemiBold"/>
<TextBlock x:Name="TotalSpeedText" Grid.Column="1" VerticalAlignment="Center"
Margin="16,0,16,0" Opacity="0.75"/>
<ui:Button x:Name="CancelCloseButton" Grid.Column="2" Content="Cancel" Width="100"
Appearance="Secondary" Click="CancelCloseButton_Click"/>
</Grid>
</Grid>
</Grid>
</ui:FluentWindow>
+216
View File
@@ -0,0 +1,216 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Windows;
using Futonizer.Models;
using Futonizer.Services;
using Wpf.Ui.Controls;
namespace Futonizer.Views;
public partial class ProgressWindow : FluentWindow
{
private const int MaxConcurrentStrips = 5;
private readonly IReadOnlyList<QueuedFileItem> _files;
private readonly string _mkvMergePath;
private readonly string _outputFolder;
private readonly ObservableCollection<JobProgressItem> _activeJobs = new();
private CancellationTokenSource? _cts;
private bool _isDone;
/// <summary>
/// True once processing has finished running (successfully, with failures,
/// or cancelled) — i.e. whenever the Cancel button has turned into Close.
/// Used by the caller to decide whether to clear its queue after this
/// window is closed.
/// </summary>
public bool ProcessingCompleted { get; private set; }
public ProgressWindow(IReadOnlyList<QueuedFileItem> files, string mkvMergePath, string outputFolder)
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
_files = files;
_mkvMergePath = mkvMergePath;
_outputFolder = outputFolder;
Title = $"Stripping {files.Count} file{(files.Count == 1 ? "" : "s")}";
ActiveJobsList.ItemsSource = _activeJobs;
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
_ = StartProcessingAsync();
}
private void Window_Closing(object sender, CancelEventArgs e)
{
_cts?.Cancel();
}
private void CancelCloseButton_Click(object sender, RoutedEventArgs e)
{
if (_isDone)
{
Close();
return;
}
_cts?.Cancel();
CancelCloseButton.IsEnabled = false;
StatusText.Text = "Cancelling...";
}
private void AppendLog(string line)
{
LogBox.AppendText(line + Environment.NewLine);
LogBox.ScrollToEnd();
}
private void UpdateTotalSpeed()
{
if (_activeJobs.Count == 0)
{
TotalSpeedText.Text = string.Empty;
return;
}
double total = _activeJobs.Sum(j => j.MegabytesPerSecond);
TotalSpeedText.Text = _activeJobs.Count == 1
? $"{total:F1} MB/s"
: $"{total:F1} MB/s across {_activeJobs.Count} file(s)";
}
private async Task StartProcessingAsync()
{
StatusText.Text = "Stripping tracks...";
_cts = new CancellationTokenSource();
var ct = _cts.Token;
bool overwritingInPlace = string.IsNullOrWhiteSpace(_outputFolder);
var service = new MkvService(_mkvMergePath);
AppendLog($"Starting track removal for {_files.Count} file(s) (up to {MaxConcurrentStrips} at a time)...");
AppendLog(new string('-', 80));
int successCount = 0;
long totalBytesWritten = 0;
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
using var semaphore = new SemaphoreSlim(MaxConcurrentStrips);
var stripTasks = _files.Select(async file =>
{
await semaphore.WaitAsync(ct);
var job = new JobProgressItem { FileName = file.FileName };
job.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
UpdateTotalSpeed();
};
_activeJobs.Add(job);
UpdateTotalSpeed();
try
{
string outputFilePath = overwritingInPlace
? file.FilePath
: Path.Combine(_outputFolder, file.FileName);
var videoIds = file.Tracks
.Where(t => t.Type == "video" && t.Copy)
.Select(t => t.Id).ToList();
var audioIds = file.Tracks
.Where(t => t.Type == "audio" && t.Copy)
.Select(t => t.Id).ToList();
var subIds = file.Tracks
.Where(t => t.Type == "subtitles" && t.Copy)
.Select(t => t.Id).ToList();
bool nothingToStrip = file.Tracks.All(t => t.Copy);
var progress = new Progress<ProcessingProgress>(p =>
{
job.Percent = p.PercentComplete;
job.MegabytesPerSecond = p.MegabytesPerSecond;
});
var result = nothingToStrip
? await service.CopyFileAsync(file.FilePath, outputFilePath, progress, ct)
: await service.StripTracksAsync(
file.FilePath, outputFilePath,
videoIds, audioIds, subIds,
progress, ct);
string tag = result.Success ? "[DONE]" : "[FAIL]";
AppendLog($"{tag} {result.FileName} — {result.Message}");
HistoryService.Add(new HistoryEntry
{
Timestamp = DateTime.Now,
FileName = result.FileName,
FilePath = file.FilePath,
Success = result.Success,
Message = result.Message,
});
if (result.Success)
{
successCount++;
try
{
long bytes = new FileInfo(outputFilePath).Length;
Interlocked.Add(ref totalBytesWritten, bytes);
}
catch { /* best effort */ }
}
}
finally
{
_activeJobs.Remove(job);
UpdateTotalSpeed();
semaphore.Release();
}
}).ToList();
await Task.WhenAll(stripTasks);
AppendLog(new string('-', 80));
AppendLog($"Processing complete: {successCount}/{_files.Count} file(s) succeeded.");
stopwatch.Stop();
double totalSec = stopwatch.Elapsed.TotalSeconds;
double totalMb = totalBytesWritten / 1024.0 / 1024.0;
double avgMbps = totalSec > 0 ? totalMb / totalSec : 0;
AppendLog($"Total data written: {totalMb / 1024.0:F2} GB in {totalSec:F1}s (avg {avgMbps:F1} MB/s combined).");
StatusText.Text = $"Done: {successCount}/{_files.Count} succeeded.";
}
catch (OperationCanceledException)
{
AppendLog(new string('-', 80));
AppendLog("Cancelled by user. Nothing further was modified.");
StatusText.Text = "Cancelled.";
}
catch (Exception ex)
{
AppendLog($"Unexpected error: {ex.Message}");
StatusText.Text = "Error.";
}
finally
{
_activeJobs.Clear();
TotalSpeedText.Text = string.Empty;
_cts?.Dispose();
_cts = null;
_isDone = true;
ProcessingCompleted = true;
CancelCloseButton.IsEnabled = true;
CancelCloseButton.Content = "Close";
}
}
}
+72
View File
@@ -0,0 +1,72 @@
<ui:FluentWindow x:Class="Futonizer.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="Settings"
Height="320" Width="540"
MinHeight="320" MinWidth="540"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="Settings"/>
<Grid Grid.Row="1" Margin="20,8,20,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- mkvmerge path -->
<TextBlock Grid.Row="0" Text="mkvmerge.exe path" FontSize="12" Opacity="0.7" Margin="0,0,0,4"/>
<Grid Grid.Row="1" Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ui:TextBox x:Name="MkvMergePathBox" Grid.Column="0" Margin="0,0,8,0"
PlaceholderText="Path to mkvmerge.exe"/>
<ui:Button x:Name="BrowseMkvMergeButton" Grid.Column="1" Content="Browse..." Width="100"
Click="BrowseMkvMergeButton_Click"/>
</Grid>
<!-- Output folder -->
<TextBlock Grid.Row="2" Text="Output folder (optional — blank overwrites files in place)" FontSize="12" Opacity="0.7" Margin="0,0,0,4"/>
<Grid Grid.Row="3" Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ui:TextBox x:Name="OutputFolderBox" Grid.Column="0" Margin="0,0,8,0"
PlaceholderText="Leave blank to overwrite original files"/>
<ui:Button x:Name="BrowseOutputFolderButton" Grid.Column="1" Content="Browse..." Width="100"
Click="BrowseOutputFolderButton_Click"/>
</Grid>
<!-- History -->
<ui:Button x:Name="ViewHistoryButton" Grid.Row="4" Content="View History..."
HorizontalAlignment="Left" Appearance="Secondary"
Click="ViewHistoryButton_Click"/>
<!-- Buttons -->
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right">
<ui:Button x:Name="CancelButton" Content="Cancel" Width="90" Margin="0,0,10,0"
Appearance="Secondary" Click="CancelButton_Click"/>
<ui:Button x:Name="SaveButton" Content="Save" Width="90"
Appearance="Primary" Click="SaveButton_Click"/>
</StackPanel>
</Grid>
</Grid>
</ui:FluentWindow>
+60
View File
@@ -0,0 +1,60 @@
using System.IO;
using System.Windows;
using Microsoft.Win32;
using Futonizer.Models;
using Wpf.Ui.Controls;
namespace Futonizer.Views;
public partial class SettingsWindow : FluentWindow
{
public AppSettings? Result { get; private set; }
public SettingsWindow(AppSettings current)
{
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
InitializeComponent();
MkvMergePathBox.Text = current.MkvMergePath;
OutputFolderBox.Text = current.OutputFolder;
}
private void BrowseMkvMergeButton_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Locate mkvmerge.exe",
Filter = "mkvmerge.exe|mkvmerge.exe|Executable files (*.exe)|*.exe|All files (*.*)|*.*",
CheckFileExists = true,
};
if (dlg.ShowDialog(this) == true)
MkvMergePathBox.Text = dlg.FileName;
}
private void BrowseOutputFolderButton_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog { Title = "Select output folder" };
if (dlg.ShowDialog(this) == true)
OutputFolderBox.Text = dlg.FolderName;
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
Result = new AppSettings
{
MkvMergePath = MkvMergePathBox.Text.Trim(),
OutputFolder = OutputFolderBox.Text.Trim(),
};
DialogResult = true;
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
private void ViewHistoryButton_Click(object sender, RoutedEventArgs e)
{
var win = new HistoryWindow { Owner = this };
win.ShowDialog();
}
}