- Unify audio/subtitle selection: single-select, auto-default, cross-file propagation by exact name - Show file size in queue, group divider and reordered columns in track table, drop accent highlight on row selection - Add cumulative progress bar (files finished, MB copied) to the stripping window - Add Windows toast notifications on stripping completion/cancel/error - Restart the app after settings are changed; Cancel no longer clears the queue, only closes the window - Add ConfigureAwait(false) throughout MkvService so concurrent strips don't starve the UI thread - Hardcode scan/strip concurrency (10/5) instead of exposing it in Settings
264 lines
9.6 KiB
C#
264 lines
9.6 KiB
C#
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 readonly int _maxConcurrentStrips;
|
|
|
|
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;
|
|
private long _totalInputBytes;
|
|
private long _completedBytes;
|
|
private int _finishedCount;
|
|
|
|
/// <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; }
|
|
|
|
/// <summary>
|
|
/// True only if processing ran through to a normal finish (whether
|
|
/// individual files succeeded or failed). False if the run was cancelled
|
|
/// or crashed with an unexpected error — in either of those cases the
|
|
/// caller should leave its queue untouched rather than clearing it.
|
|
/// </summary>
|
|
public bool ShouldClearQueue { get; private set; }
|
|
|
|
public ProgressWindow(IReadOnlyList<QueuedFileItem> files, string mkvMergePath, string outputFolder, int maxConcurrentStrips = 5)
|
|
{
|
|
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
|
|
InitializeComponent();
|
|
|
|
_files = files;
|
|
_mkvMergePath = mkvMergePath;
|
|
_outputFolder = outputFolder;
|
|
_maxConcurrentStrips = maxConcurrentStrips > 0 ? maxConcurrentStrips : 5;
|
|
|
|
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 void UpdateTotalProgress()
|
|
{
|
|
long activeBytes = _activeJobs.Sum(j => (long)(j.FileSizeBytes * (j.Percent / 100.0)));
|
|
long doneBytes = Math.Min(_totalInputBytes, _completedBytes + activeBytes);
|
|
|
|
TotalProgressBar.Value = _totalInputBytes > 0
|
|
? doneBytes * 100.0 / _totalInputBytes
|
|
: 0;
|
|
|
|
TotalFilesText.Text = $"{_finishedCount}/{_files.Count} file{(_files.Count == 1 ? "" : "s")} finished";
|
|
|
|
double doneMb = doneBytes / 1024.0 / 1024.0;
|
|
double totalMb = _totalInputBytes / 1024.0 / 1024.0;
|
|
TotalBytesText.Text = $"{doneMb:F0} / {totalMb:F0} MB copied";
|
|
}
|
|
|
|
private static long SafeFileLength(string path)
|
|
{
|
|
try { return new FileInfo(path).Length; }
|
|
catch { return 0; }
|
|
}
|
|
|
|
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();
|
|
_totalInputBytes = _files.Sum(f => SafeFileLength(f.FilePath));
|
|
_completedBytes = 0;
|
|
_finishedCount = 0;
|
|
UpdateTotalProgress();
|
|
|
|
try
|
|
{
|
|
using var semaphore = new SemaphoreSlim(_maxConcurrentStrips);
|
|
|
|
var stripTasks = _files.Select(async file =>
|
|
{
|
|
await semaphore.WaitAsync(ct);
|
|
|
|
var job = new JobProgressItem { FileName = file.FileName, FileSizeBytes = SafeFileLength(file.FilePath) };
|
|
job.PropertyChanged += (_, args) =>
|
|
{
|
|
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
|
|
UpdateTotalSpeed();
|
|
if (args.PropertyName == nameof(JobProgressItem.Percent))
|
|
UpdateTotalProgress();
|
|
};
|
|
_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);
|
|
Interlocked.Add(ref _completedBytes, job.FileSizeBytes);
|
|
Interlocked.Increment(ref _finishedCount);
|
|
UpdateTotalSpeed();
|
|
UpdateTotalProgress();
|
|
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.";
|
|
NotificationService.ShowToast("Futonizer", $"Done: {successCount}/{_files.Count} file{(_files.Count == 1 ? "" : "s")} succeeded.");
|
|
ShouldClearQueue = true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
AppendLog(new string('-', 80));
|
|
AppendLog("Cancelled by user. Nothing further was modified.");
|
|
StatusText.Text = "Cancelled.";
|
|
NotificationService.ShowToast("Futonizer", "Cancelled — nothing further was modified.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppendLog($"Unexpected error: {ex.Message}");
|
|
StatusText.Text = "Error.";
|
|
NotificationService.ShowToast("Futonizer", $"Error: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
_activeJobs.Clear();
|
|
TotalSpeedText.Text = string.Empty;
|
|
_cts?.Dispose();
|
|
_cts = null;
|
|
_isDone = true;
|
|
ProcessingCompleted = true;
|
|
CancelCloseButton.IsEnabled = true;
|
|
CancelCloseButton.Content = "Close";
|
|
}
|
|
}
|
|
}
|