Initial commit: Futonizer MKV track stripper
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Futonizer.Models;
|
||||
|
||||
namespace Futonizer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists a rolling history of previously processed files (newest first,
|
||||
/// capped at <see cref="MaxEntries"/>) as a JSON file stored next to the
|
||||
/// application executable.
|
||||
/// </summary>
|
||||
public static class HistoryService
|
||||
{
|
||||
private const int MaxEntries = 1000;
|
||||
private static readonly string HistoryFilePath =
|
||||
Path.Combine(AppContext.BaseDirectory, "history.json");
|
||||
private static readonly object FileLock = new();
|
||||
|
||||
public static List<HistoryEntry> Load()
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(HistoryFilePath)) return new List<HistoryEntry>();
|
||||
string json = File.ReadAllText(HistoryFilePath);
|
||||
var list = JsonSerializer.Deserialize<List<HistoryEntry>>(json);
|
||||
return list ?? new List<HistoryEntry>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<HistoryEntry>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new entry at the front of the history and trims it down to
|
||||
/// <see cref="MaxEntries"/> items. Safe to call concurrently from
|
||||
/// multiple in-flight strip jobs.
|
||||
/// </summary>
|
||||
public static void Add(HistoryEntry entry)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = File.Exists(HistoryFilePath)
|
||||
? JsonSerializer.Deserialize<List<HistoryEntry>>(File.ReadAllText(HistoryFilePath)) ?? new List<HistoryEntry>()
|
||||
: new List<HistoryEntry>();
|
||||
|
||||
list.Insert(0, entry);
|
||||
if (list.Count > MaxEntries)
|
||||
list.RemoveRange(MaxEntries, list.Count - MaxEntries);
|
||||
|
||||
string json = JsonSerializer.Serialize(list, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(HistoryFilePath, json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort — history is a convenience feature, never worth failing a strip job over.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
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);
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
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 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.
|
||||
/// </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();
|
||||
|
||||
return 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();
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
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,
|
||||
IProgress<ProcessingProgress>? progress = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return StripTracksInternalAsync(
|
||||
filePath, Path.GetFileName(filePath), outputFilePath,
|
||||
videoKeepIds, audioKeepIds, subtitleKeepIds,
|
||||
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,
|
||||
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)) > 0)
|
||||
{
|
||||
await dest.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
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,
|
||||
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);
|
||||
|
||||
// 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);
|
||||
throughputCts.Cancel();
|
||||
string stdout = await stdoutTask;
|
||||
string stderr = await stderrTask;
|
||||
try { await throughputTask; } 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()) != 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);
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Futonizer.Models;
|
||||
|
||||
namespace Futonizer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves <see cref="AppSettings"/> as a JSON file stored next to
|
||||
/// the application executable, so it travels with the app folder.
|
||||
/// </summary>
|
||||
public static class SettingsService
|
||||
{
|
||||
private static readonly string SettingsFilePath = Path.Combine(AppContext.BaseDirectory, "settings.json");
|
||||
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
||||
|
||||
public static AppSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(SettingsFilePath))
|
||||
{
|
||||
string json = File.ReadAllText(SettingsFilePath);
|
||||
var settings = JsonSerializer.Deserialize<AppSettings>(json, Options);
|
||||
if (settings is not null)
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Corrupt or unreadable settings file - fall back to defaults.
|
||||
}
|
||||
|
||||
return new AppSettings();
|
||||
}
|
||||
|
||||
public static void Save(AppSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = JsonSerializer.Serialize(settings, Options);
|
||||
File.WriteAllText(SettingsFilePath, json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort - ignore write failures (e.g. read-only install folder).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Futonizer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scrolls a list/grid by an amount proportional to the mouse wheel delta
|
||||
/// instead of relying on WPF's default per-event line scrolling. Mouse
|
||||
/// "smooth scroll" utilities send many finer-grained wheel events per
|
||||
/// physical notch to animate motion; WPF's default handling treats each
|
||||
/// event as a full notch regardless of its magnitude, which multiplies the
|
||||
/// effective scroll speed far beyond what the user intended.
|
||||
/// </summary>
|
||||
public static class SmoothScrollHelper
|
||||
{
|
||||
private const double PixelsPerStandardNotch = 48.0;
|
||||
|
||||
public static void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
if (sender is not DependencyObject start) return;
|
||||
var scrollViewer = FindVisualChild<ScrollViewer>(start);
|
||||
if (scrollViewer == null) return;
|
||||
|
||||
double offsetDelta = -(e.Delta / 120.0) * PixelsPerStandardNotch;
|
||||
scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset + offsetDelta);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static T? FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T typed) return typed;
|
||||
|
||||
var descendant = FindVisualChild<T>(child);
|
||||
if (descendant != null) return descendant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user