Files
Futonizer/Views/ProgressWindow.xaml.cs
l4kr c7c125d40b Match and mux in dropped external subtitles
Drag in subtitle files alongside .mkv files and they're matched to
the right video by episode code (S04E06, zero-padding and separator
insensitive, plus 4x06 as a fallback) and muxed in as extra subtitle
tracks. Any subtitle format mkvmerge accepts is recognized: SRT,
(S)SA, VobSub, WebVTT, PGS/SUP, USF, SAMI, TTML/DFXP, STL, Kate.

Matching works regardless of drop order — subtitles dropped before
their video are held pending until a matching video shows up, and
vice versa. Language, forced, and SDH flags are guessed from common
filename tokens (e.g. "eng", "forced", "sdh") to set the muxed
track's language/name/flags; unmatched subtitles are reported in the
status bar.
2026-07-08 10:37:56 +02:00

271 lines
10 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);
string computedFileName = FileNameHelper.ComputeOutputFileName(file.FilePath);
var job = new JobProgressItem { FileName = computedFileName, 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 sourceDir = Path.GetDirectoryName(file.FilePath) ?? "";
string outputFilePath = overwritingInPlace
? Path.Combine(sourceDir, computedFileName)
: Path.Combine(_outputFolder, computedFileName);
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) && file.ExternalSubtitles.Count == 0;
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,
file.ExternalSubtitles.ToList(),
progress, ct);
string tag = result.Success ? "[DONE]" : "[FAIL]";
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
{
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";
}
}
}