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 pre-checked: /// among Japanese/Chinese/undefined-language tracks, a stereo (2-channel) /// track is preferred, otherwise the first matching track in file order. /// 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(); 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, IProgress? progress = null, CancellationToken ct = default) { return StripTracksInternalAsync( filePath, Path.GetFileName(filePath), outputFilePath, videoKeepIds, audioKeepIds, subtitleKeepIds, 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, 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, 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); // The one subtitle track the user chose to keep is always flagged // as the default subtitle track in the output. if (subtitleKeepIds != null && subtitleKeepIds.Count == 1) { psi.ArgumentList.Add("--default-track-flag"); psi.ArgumentList.Add($"{subtitleKeepIds[0]}:yes"); } psi.ArgumentList.Add(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) { } } }