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);
/// Path of the file relative to the scanned media folder root.
public string RelativePath { get; set; } = string.Empty;
public bool Passed { get; set; }
public string Message { get; set; } = string.Empty;
public List AudioKeepIds { get; set; } = new();
public List 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;
}
///
/// Live progress reported while a file is being remuxed.
///
public class ProcessingProgress
{
public string FileName { get; set; } = string.Empty;
public int PercentComplete { get; set; }
public double MegabytesPerSecond { get; set; }
}
///
/// Wraps calls to mkvmerge.exe: identifying tracks (via -J / JSON) and
/// remuxing files to keep only the wanted audio/subtitle tracks.
///
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 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(stdout, options);
return result ?? throw new InvalidOperationException($"Failed to parse mkvmerge output for '{Path.GetFileName(filePath)}'.");
}
///
/// Builds the list of 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.
///
public List 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;
}
///
/// 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.
///
public async Task 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";
}
///
/// Accepts Japanese, Chinese, or undefined-language audio tracks.
///
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
// -------------------------------------------------------------------------
///
/// 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
/// objects whose is true.
///
public Task StripTracksAsync(
string filePath,
string outputFilePath,
IReadOnlyList videoKeepIds,
IReadOnlyList audioKeepIds,
IReadOnlyList subtitleKeepIds,
IReadOnlyList? externalSubtitles = null,
IProgress? progress = null,
CancellationToken ct = default)
{
return StripTracksInternalAsync(
filePath, Path.GetFileName(filePath), outputFilePath,
videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
progress, ct);
}
///
/// Legacy overload using a . Video is always kept in full.
///
public Task StripTracksAsync(
ScanResult scan,
string outputFilePath,
IProgress? progress = null,
CancellationToken ct = default)
{
return StripTracksInternalAsync(
scan.FilePath, scan.FileName, outputFilePath,
null, // null = keep all video
scan.AudioKeepIds,
scan.SubtitleKeepIds,
null,
progress, ct);
}
///
/// 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 does.
///
public async Task CopyFileAsync(
string filePath,
string outputFilePath,
IProgress? 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
// -------------------------------------------------------------------------
/// null = keep all video; empty = drop all video; otherwise keep listed IDs.
/// null = keep all audio; empty = drop all audio; otherwise keep listed IDs.
/// null = keep all subtitles; empty = drop all subtitles; otherwise keep listed IDs.
private async Task StripTracksInternalAsync(
string filePath,
string fileName,
string outputFilePath,
IReadOnlyList? videoKeepIds,
IReadOnlyList? audioKeepIds,
IReadOnlyList? subtitleKeepIds,
IReadOnlyList? externalSubtitles,
IProgress? 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;
}
}
///
/// Adds mkvmerge track-filter arguments for one track type.
/// null ids = keep all (no argument added); empty = --no-{type}; else --{type}-tracks ids.
///
private static void AddTrackTypeArgs(ProcessStartInfo psi, string keepArg, string dropArg, IReadOnlyList? 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 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? 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) { }
}
}