Files
Futonizer/Views/ProgressWindow.xaml.cs
T
2026-07-04 12:01:13 +02:00

287 lines
11 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Text.RegularExpressions;
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";
}
/// <summary>
/// If the source file's parent folder starts with a bracketed tag
/// (e.g. "[KAA]" or "[neoHEVC]") and the file name doesn't already
/// start with that tag, returns the file name with the tag prepended
/// (e.g. "[KAA] episode.mkv"). Otherwise returns the file name unchanged.
/// Only applied to the output file — the source is never touched.
/// </summary>
private static string PrefixedFileName(string sourceFilePath, string fileName)
{
string? folder = Path.GetDirectoryName(sourceFilePath);
if (string.IsNullOrEmpty(folder)) return fileName;
string folderName = Path.GetFileName(folder);
var match = Regex.Match(folderName, @"^\[.+?\]");
if (!match.Success) return fileName;
string prefix = match.Value + " "; // e.g. "[KAA] "
if (fileName.StartsWith(match.Value, StringComparison.Ordinal)) return fileName;
return prefix + fileName;
}
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, PrefixedFileName(file.FilePath, 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";
}
}
}