Auto-selection still prefers Japanese/Chinese/undefined audio, but if a file has none of those (e.g. only English), the first audio track is pre-checked instead of leaving no audio track selected.
643 lines
25 KiB
C#
643 lines
25 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using Futonizer.Models;
|
|
|
|
namespace Futonizer.Services;
|
|
|
|
public class ScanResult
|
|
{
|
|
public string FilePath { get; set; } = string.Empty;
|
|
public string FileName => Path.GetFileName(FilePath);
|
|
|
|
/// <summary>Path of the file relative to the scanned media folder root.</summary>
|
|
public string RelativePath { get; set; } = string.Empty;
|
|
public bool Passed { get; set; }
|
|
public string Message { get; set; } = string.Empty;
|
|
public List<int> AudioKeepIds { get; set; } = new();
|
|
public List<int> SubtitleKeepIds { get; set; } = new();
|
|
}
|
|
|
|
public class ProcessResult
|
|
{
|
|
public string SourceFilePath { get; set; } = string.Empty;
|
|
public string FileName => Path.GetFileName(SourceFilePath);
|
|
public string OutputFilePath { get; set; } = string.Empty;
|
|
public bool Success { get; set; }
|
|
public string Message { get; set; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Live progress reported while a file is being remuxed.
|
|
/// </summary>
|
|
public class ProcessingProgress
|
|
{
|
|
public string FileName { get; set; } = string.Empty;
|
|
public int PercentComplete { get; set; }
|
|
public double MegabytesPerSecond { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wraps calls to mkvmerge.exe: identifying tracks (via -J / JSON) and
|
|
/// remuxing files to keep only the wanted audio/subtitle tracks.
|
|
/// </summary>
|
|
public class MkvService
|
|
{
|
|
private static readonly Regex ProgressRegex = new(@"(?:#GUI#progress|Progress:)\s*(\d+)%", RegexOptions.Compiled);
|
|
|
|
private readonly string _mkvmergePath;
|
|
|
|
public MkvService(string mkvmergePath)
|
|
{
|
|
_mkvmergePath = mkvmergePath;
|
|
}
|
|
|
|
public async Task<MkvIdentifyResult> IdentifyAsync(string filePath, CancellationToken ct = default)
|
|
{
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = _mkvmergePath,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
};
|
|
psi.ArgumentList.Add("-J");
|
|
psi.ArgumentList.Add(filePath);
|
|
|
|
using var process = new Process { StartInfo = psi };
|
|
process.Start();
|
|
|
|
// Drain both streams concurrently. mkvmerge sometimes writes warnings to
|
|
// stderr; if it isn't read while we await stdout, the OS pipe buffer can
|
|
// fill up and deadlock the child process indefinitely.
|
|
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
|
|
var stderrTask = process.StandardError.ReadToEndAsync(ct);
|
|
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
|
|
await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
|
|
|
string stdout = stdoutTask.Result;
|
|
string stderr = stderrTask.Result;
|
|
|
|
if (string.IsNullOrWhiteSpace(stdout))
|
|
{
|
|
string detail = string.IsNullOrWhiteSpace(stderr) ? string.Empty : $" ({stderr.Trim()})";
|
|
throw new InvalidOperationException($"mkvmerge returned no output for '{Path.GetFileName(filePath)}'.{detail}");
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
var result = JsonSerializer.Deserialize<MkvIdentifyResult>(stdout, options);
|
|
return result ?? throw new InvalidOperationException($"Failed to parse mkvmerge output for '{Path.GetFileName(filePath)}'.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
|
|
/// tracks are always pre-checked. Exactly one audio track is always
|
|
/// pre-checked: among Japanese/Chinese/undefined-language tracks, a
|
|
/// stereo (2-channel) track is preferred, otherwise the first matching
|
|
/// track in file order. If no such track exists (e.g. only English
|
|
/// audio), the very first audio track in the file is pre-checked instead,
|
|
/// so a file is never left with no audio track selected.
|
|
/// Subtitle tracks are never pre-checked — the user picks one via the
|
|
/// track table, and the choice propagates to other queued files.
|
|
/// </summary>
|
|
public List<TrackItem> BuildTrackItems(MkvIdentifyResult info)
|
|
{
|
|
var audioTracks = info.Tracks.Where(t => t.Type == "audio").ToList();
|
|
var eligibleAudio = audioTracks
|
|
.Where(t => IsAcceptableAudioLanguage(t.Properties?.Language) ||
|
|
IsAcceptableAudioLanguage(t.Properties?.LanguageIetf))
|
|
.ToList();
|
|
|
|
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
|
|
?? eligibleAudio.FirstOrDefault()
|
|
?? audioTracks.FirstOrDefault();
|
|
|
|
var items = info.Tracks.Select(t =>
|
|
{
|
|
bool copy = t.Type switch
|
|
{
|
|
"video" => true,
|
|
"audio" => preferredAudio != null && t.Id == preferredAudio.Id,
|
|
_ => false,
|
|
};
|
|
|
|
// Prefer IETF tag (e.g. "ja") over legacy 3-letter code (e.g. "jpn").
|
|
string lang = t.Properties?.LanguageIetf
|
|
?? t.Properties?.Language
|
|
?? string.Empty;
|
|
|
|
return new TrackItem
|
|
{
|
|
Id = t.Id,
|
|
Type = t.Type,
|
|
Codec = t.Codec,
|
|
Language = lang,
|
|
Name = t.Properties?.TrackName ?? string.Empty,
|
|
DefaultTrack = t.Properties?.DefaultTrack ?? false,
|
|
ForcedDisplay = t.Properties?.ForcedTrack ?? false,
|
|
Copy = copy,
|
|
};
|
|
}).ToList();
|
|
|
|
int chapterCount = info.Chapters.Sum(c => c.NumEntries);
|
|
if (chapterCount > 0)
|
|
{
|
|
items.Add(new TrackItem
|
|
{
|
|
Id = -1,
|
|
Type = "chapters",
|
|
Codec = string.Empty,
|
|
Language = string.Empty,
|
|
Name = $"Chapters ({chapterCount})",
|
|
Copy = true,
|
|
});
|
|
}
|
|
|
|
for (int i = 0; i < items.Count; i++)
|
|
{
|
|
items[i].IsGroupStart = i > 0 && items[i].Type != items[i - 1].Type;
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Scans a single file and determines which audio/subtitle track IDs
|
|
/// should be kept. Fails if only English audio is present, or if the
|
|
/// requested subtitle track name cannot be found.
|
|
/// </summary>
|
|
public async Task<ScanResult> ScanFileAsync(string filePath, string subtitleTrackName, CancellationToken ct = default)
|
|
{
|
|
var scan = new ScanResult { FilePath = filePath };
|
|
try
|
|
{
|
|
var info = await IdentifyAsync(filePath, ct).ConfigureAwait(false);
|
|
|
|
var audioTracks = info.Tracks.Where(t => t.Type == "audio").ToList();
|
|
var subtitleTracks = info.Tracks.Where(t => t.Type == "subtitles").ToList();
|
|
|
|
if (audioTracks.Count == 0)
|
|
{
|
|
scan.Passed = false;
|
|
scan.Message = "No audio tracks found.";
|
|
return scan;
|
|
}
|
|
|
|
var keepAudio = audioTracks
|
|
.Where(t => IsJapaneseOrUndefined(t.Properties?.Language) || IsJapaneseOrUndefined(t.Properties?.LanguageIetf))
|
|
.ToList();
|
|
|
|
if (keepAudio.Count == 0)
|
|
{
|
|
scan.Passed = false;
|
|
scan.Message = "Only English (or other non-Japanese/undefined) audio found.";
|
|
return scan;
|
|
}
|
|
|
|
if (subtitleTracks.Count == 0)
|
|
{
|
|
scan.Passed = false;
|
|
scan.Message = "No subtitle tracks found.";
|
|
return scan;
|
|
}
|
|
|
|
var keepSubs = subtitleTracks
|
|
.Where(t => string.Equals(t.Properties?.TrackName?.Trim(), subtitleTrackName.Trim(), StringComparison.Ordinal))
|
|
.ToList();
|
|
|
|
if (keepSubs.Count == 0)
|
|
{
|
|
scan.Passed = false;
|
|
scan.Message = $"Subtitle track named \"{subtitleTrackName}\" not found.";
|
|
return scan;
|
|
}
|
|
|
|
scan.AudioKeepIds = keepAudio.Select(t => t.Id).ToList();
|
|
scan.SubtitleKeepIds = keepSubs.Select(t => t.Id).ToList();
|
|
scan.Passed = true;
|
|
scan.Message = $"OK - keep audio [{string.Join(",", scan.AudioKeepIds)}], subtitle [{string.Join(",", scan.SubtitleKeepIds)}]";
|
|
return scan;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
scan.Passed = false;
|
|
scan.Message = $"Error reading file: {ex.Message}";
|
|
return scan;
|
|
}
|
|
}
|
|
|
|
private static bool IsJapaneseOrUndefined(string? lang)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(lang)) return true;
|
|
lang = lang.Trim().ToLowerInvariant();
|
|
return lang is "jpn" or "ja" or "und";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts Japanese, Chinese, or undefined-language audio tracks.
|
|
/// </summary>
|
|
private static bool IsAcceptableAudioLanguage(string? lang)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(lang)) return true;
|
|
lang = lang.Trim().ToLowerInvariant();
|
|
return lang is "jpn" or "ja" or "und"
|
|
or "chi" or "zho" or "zh" or "cmn" or "yue";
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Public strip overloads
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Remuxes a file, keeping only the tracks indicated by the three ID lists.
|
|
/// An empty list means "drop all tracks of that type"; pass the IDs from
|
|
/// <see cref="TrackItem"/> objects whose <see cref="TrackItem.Copy"/> is true.
|
|
/// </summary>
|
|
public Task<ProcessResult> StripTracksAsync(
|
|
string filePath,
|
|
string outputFilePath,
|
|
IReadOnlyList<int> videoKeepIds,
|
|
IReadOnlyList<int> audioKeepIds,
|
|
IReadOnlyList<int> subtitleKeepIds,
|
|
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles = null,
|
|
IProgress<ProcessingProgress>? progress = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
return StripTracksInternalAsync(
|
|
filePath, Path.GetFileName(filePath), outputFilePath,
|
|
videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
|
|
progress, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Legacy overload using a <see cref="ScanResult"/>. Video is always kept in full.
|
|
/// </summary>
|
|
public Task<ProcessResult> StripTracksAsync(
|
|
ScanResult scan,
|
|
string outputFilePath,
|
|
IProgress<ProcessingProgress>? progress = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
return StripTracksInternalAsync(
|
|
scan.FilePath, scan.FileName, outputFilePath,
|
|
null, // null = keep all video
|
|
scan.AudioKeepIds,
|
|
scan.SubtitleKeepIds,
|
|
null,
|
|
progress, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plain byte-for-byte file copy, used instead of an mkvmerge remux when
|
|
/// every track in the source file is already selected to be kept (so
|
|
/// nothing would actually be stripped). Supports cancellation and
|
|
/// reports throughput the same way <see cref="StripTracksAsync"/> does.
|
|
/// </summary>
|
|
public async Task<ProcessResult> CopyFileAsync(
|
|
string filePath,
|
|
string outputFilePath,
|
|
IProgress<ProcessingProgress>? progress = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
string fileName = Path.GetFileName(filePath);
|
|
var result = new ProcessResult { SourceFilePath = filePath, OutputFilePath = outputFilePath };
|
|
|
|
string fullSource = Path.GetFullPath(filePath);
|
|
string fullOutput = Path.GetFullPath(outputFilePath);
|
|
|
|
if (string.Equals(fullSource, fullOutput, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Overwriting in place with no tracks removed — there is nothing to do.
|
|
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = 100, MegabytesPerSecond = 0 });
|
|
result.Success = true;
|
|
result.Message = "No tracks removed — left file unchanged";
|
|
return result;
|
|
}
|
|
|
|
string? outDir = Path.GetDirectoryName(fullOutput);
|
|
string tempFile = Path.Combine(outDir ?? "", Path.GetFileNameWithoutExtension(fullOutput) + ".tmp_stripped.mkv");
|
|
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(outDir))
|
|
Directory.CreateDirectory(outDir);
|
|
if (File.Exists(tempFile))
|
|
File.Delete(tempFile);
|
|
|
|
long totalBytes = new FileInfo(fullSource).Length;
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var lastReport = TimeSpan.Zero;
|
|
long copied = 0;
|
|
|
|
await using (var source = new FileStream(fullSource, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, useAsync: true))
|
|
await using (var dest = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, useAsync: true))
|
|
{
|
|
byte[] buffer = new byte[1024 * 1024];
|
|
int read;
|
|
while ((read = await source.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
|
|
{
|
|
await dest.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
|
|
copied += read;
|
|
|
|
var elapsed = stopwatch.Elapsed;
|
|
if ((elapsed - lastReport).TotalMilliseconds >= 200 || copied >= totalBytes)
|
|
{
|
|
double mbps = elapsed.TotalSeconds > 0 ? (copied / 1024.0 / 1024.0) / elapsed.TotalSeconds : 0;
|
|
int percent = totalBytes > 0 ? (int)Math.Min(99, copied * 100 / totalBytes) : 0;
|
|
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = percent, MegabytesPerSecond = mbps });
|
|
lastReport = elapsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = 100, MegabytesPerSecond = 0 });
|
|
|
|
if (File.Exists(fullOutput)) File.Delete(fullOutput);
|
|
File.Move(tempFile, fullOutput);
|
|
|
|
result.Success = true;
|
|
result.Message = "Copied (no tracks removed)";
|
|
return result;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
if (File.Exists(tempFile))
|
|
try { File.Delete(tempFile); } catch { }
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Success = false;
|
|
result.Message = $"Error: {ex.Message}";
|
|
if (File.Exists(tempFile))
|
|
try { File.Delete(tempFile); } catch { }
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal implementation
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <param name="videoKeepIds">null = keep all video; empty = drop all video; otherwise keep listed IDs.</param>
|
|
/// <param name="audioKeepIds">null = keep all audio; empty = drop all audio; otherwise keep listed IDs.</param>
|
|
/// <param name="subtitleKeepIds">null = keep all subtitles; empty = drop all subtitles; otherwise keep listed IDs.</param>
|
|
private async Task<ProcessResult> StripTracksInternalAsync(
|
|
string filePath,
|
|
string fileName,
|
|
string outputFilePath,
|
|
IReadOnlyList<int>? videoKeepIds,
|
|
IReadOnlyList<int>? audioKeepIds,
|
|
IReadOnlyList<int>? subtitleKeepIds,
|
|
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles,
|
|
IProgress<ProcessingProgress>? progress,
|
|
CancellationToken ct)
|
|
{
|
|
var result = new ProcessResult { SourceFilePath = filePath, OutputFilePath = outputFilePath };
|
|
|
|
string fullSource = Path.GetFullPath(filePath);
|
|
string fullOutput = Path.GetFullPath(outputFilePath);
|
|
bool inPlace = string.Equals(fullSource, fullOutput, StringComparison.OrdinalIgnoreCase);
|
|
|
|
string? outDir = Path.GetDirectoryName(fullOutput);
|
|
string tempFile = Path.Combine(outDir ?? "", Path.GetFileNameWithoutExtension(fullOutput) + ".tmp_stripped.mkv");
|
|
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(outDir))
|
|
Directory.CreateDirectory(outDir);
|
|
|
|
if (File.Exists(tempFile))
|
|
File.Delete(tempFile);
|
|
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = _mkvmergePath,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
};
|
|
|
|
psi.ArgumentList.Add("--gui-mode");
|
|
psi.ArgumentList.Add("-o");
|
|
psi.ArgumentList.Add(tempFile);
|
|
AddTrackTypeArgs(psi, "--video-tracks", "--no-video", videoKeepIds);
|
|
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
|
|
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
|
|
|
|
// Only one subtitle track in the whole output should ever be
|
|
// flagged default. If any external subtitle is marked default,
|
|
// it takes priority over the kept internal subtitle track.
|
|
bool hasExternalDefault = externalSubtitles != null && externalSubtitles.Any(s => s.IsDefault);
|
|
|
|
if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
|
|
{
|
|
psi.ArgumentList.Add("--default-track-flag");
|
|
psi.ArgumentList.Add(hasExternalDefault ? $"{subtitleKeepIds[0]}:no" : $"{subtitleKeepIds[0]}:yes");
|
|
}
|
|
|
|
psi.ArgumentList.Add(filePath);
|
|
|
|
// Append each matched external subtitle file as its own input,
|
|
// with per-file options (language/name/flags) scoped to it.
|
|
if (externalSubtitles != null)
|
|
{
|
|
foreach (var sub in externalSubtitles)
|
|
{
|
|
if (!string.IsNullOrEmpty(sub.Language) && !string.Equals(sub.Language, "und", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
psi.ArgumentList.Add("--language");
|
|
psi.ArgumentList.Add($"0:{sub.Language}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(sub.TrackName))
|
|
{
|
|
psi.ArgumentList.Add("--track-name");
|
|
psi.ArgumentList.Add($"0:{sub.TrackName}");
|
|
}
|
|
if (sub.Forced)
|
|
{
|
|
psi.ArgumentList.Add("--forced-display-flag");
|
|
psi.ArgumentList.Add("0:yes");
|
|
}
|
|
if (sub.HearingImpaired)
|
|
{
|
|
psi.ArgumentList.Add("--hearing-impaired-flag");
|
|
psi.ArgumentList.Add("0:yes");
|
|
}
|
|
psi.ArgumentList.Add("--default-track-flag");
|
|
psi.ArgumentList.Add(sub.IsDefault ? "0:yes" : "0:no");
|
|
|
|
psi.ArgumentList.Add(sub.FilePath);
|
|
}
|
|
}
|
|
|
|
using var process = new Process { StartInfo = psi };
|
|
process.Start();
|
|
|
|
bool wasKilled = false;
|
|
using var killOnCancel = ct.Register(() =>
|
|
{
|
|
try
|
|
{
|
|
if (!process.HasExited)
|
|
{
|
|
wasKilled = true;
|
|
process.Kill(true);
|
|
}
|
|
}
|
|
catch { /* best effort */ }
|
|
});
|
|
|
|
var percentState = new PercentState();
|
|
var stderrTask = process.StandardError.ReadToEndAsync();
|
|
var stdoutTask = ReadStdoutForPercentAsync(process, percentState);
|
|
|
|
using var throughputCts = new CancellationTokenSource();
|
|
var throughputTask = MonitorThroughputAsync(tempFile, fileName, percentState, progress, throughputCts.Token);
|
|
|
|
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
|
|
throughputCts.Cancel();
|
|
string stdout = await stdoutTask.ConfigureAwait(false);
|
|
string stderr = await stderrTask.ConfigureAwait(false);
|
|
try { await throughputTask.ConfigureAwait(false); } catch (OperationCanceledException) { }
|
|
|
|
if (wasKilled)
|
|
{
|
|
if (File.Exists(tempFile))
|
|
try { File.Delete(tempFile); } catch { }
|
|
throw new OperationCanceledException(ct);
|
|
}
|
|
|
|
progress?.Report(new ProcessingProgress { FileName = fileName, PercentComplete = 100, MegabytesPerSecond = 0 });
|
|
|
|
if (process.ExitCode >= 2)
|
|
{
|
|
result.Success = false;
|
|
result.Message = $"mkvmerge failed (exit {process.ExitCode}): {stdout} {stderr}".Trim();
|
|
if (File.Exists(tempFile)) File.Delete(tempFile);
|
|
return result;
|
|
}
|
|
|
|
if (inPlace)
|
|
{
|
|
string backupFile = fullSource + ".futonizer_bak";
|
|
if (File.Exists(backupFile)) File.Delete(backupFile);
|
|
File.Move(filePath, backupFile);
|
|
try
|
|
{
|
|
File.Move(tempFile, fullOutput);
|
|
}
|
|
catch
|
|
{
|
|
if (!File.Exists(filePath) && File.Exists(backupFile))
|
|
File.Move(backupFile, filePath);
|
|
throw;
|
|
}
|
|
if (File.Exists(backupFile)) File.Delete(backupFile);
|
|
}
|
|
else
|
|
{
|
|
if (File.Exists(fullOutput)) File.Delete(fullOutput);
|
|
File.Move(tempFile, fullOutput);
|
|
}
|
|
|
|
result.Success = true;
|
|
result.Message = process.ExitCode == 1 ? "Success (with warnings)" : "Success";
|
|
return result;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
if (File.Exists(tempFile))
|
|
try { File.Delete(tempFile); } catch { }
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Success = false;
|
|
result.Message = $"Error: {ex.Message}";
|
|
if (File.Exists(tempFile))
|
|
try { File.Delete(tempFile); } catch { }
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds mkvmerge track-filter arguments for one track type.
|
|
/// null ids = keep all (no argument added); empty = --no-{type}; else --{type}-tracks ids.
|
|
/// </summary>
|
|
private static void AddTrackTypeArgs(ProcessStartInfo psi, string keepArg, string dropArg, IReadOnlyList<int>? ids)
|
|
{
|
|
if (ids == null) return;
|
|
if (ids.Count == 0)
|
|
{
|
|
psi.ArgumentList.Add(dropArg);
|
|
}
|
|
else
|
|
{
|
|
psi.ArgumentList.Add(keepArg);
|
|
psi.ArgumentList.Add(string.Join(",", ids));
|
|
}
|
|
}
|
|
|
|
private sealed class PercentState
|
|
{
|
|
public volatile int Percent;
|
|
}
|
|
|
|
private static async Task<string> ReadStdoutForPercentAsync(Process process, PercentState state)
|
|
{
|
|
var fullOutput = new System.Text.StringBuilder();
|
|
string? line;
|
|
while ((line = await process.StandardOutput.ReadLineAsync().ConfigureAwait(false)) != null)
|
|
{
|
|
fullOutput.AppendLine(line);
|
|
var match = ProgressRegex.Match(line);
|
|
if (match.Success && int.TryParse(match.Groups[1].Value, out int percent))
|
|
state.Percent = percent;
|
|
}
|
|
return fullOutput.ToString();
|
|
}
|
|
|
|
private static async Task MonitorThroughputAsync(string tempFile, string fileName, PercentState state, IProgress<ProcessingProgress>? progress, CancellationToken stopToken)
|
|
{
|
|
var sampleInterval = TimeSpan.FromMilliseconds(500);
|
|
var windowSeconds = 3.0;
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var samples = new Queue<(double ElapsedSeconds, long Bytes)>();
|
|
samples.Enqueue((0, 0));
|
|
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
await Task.Delay(sampleInterval, stopToken).ConfigureAwait(false);
|
|
|
|
long currentBytes = 0;
|
|
try { currentBytes = new FileInfo(tempFile).Length; } catch { }
|
|
|
|
double currentElapsed = stopwatch.Elapsed.TotalSeconds;
|
|
samples.Enqueue((currentElapsed, currentBytes));
|
|
|
|
while (samples.Count > 1 && currentElapsed - samples.Peek().ElapsedSeconds > windowSeconds)
|
|
samples.Dequeue();
|
|
|
|
var (oldestElapsed, oldestBytes) = samples.Peek();
|
|
double deltaTime = currentElapsed - oldestElapsed;
|
|
double mbPerSec = deltaTime > 0 ? (currentBytes - oldestBytes) / 1024.0 / 1024.0 / deltaTime : 0;
|
|
|
|
progress?.Report(new ProcessingProgress
|
|
{
|
|
FileName = fileName,
|
|
PercentComplete = state.Percent,
|
|
MegabytesPerSecond = Math.Max(0, mbPerSec),
|
|
});
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
}
|