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
+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";
}
}
}