Initial commit: Futonizer MKV track stripper

This commit is contained in:
l4kr
2026-07-04 00:43:37 +02:00
commit 1208cadc3b
25 changed files with 2629 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
namespace Futonizer.Models;
public class AppSettings
{
public string MkvMergePath { get; set; } = string.Empty;
public string OutputFolder { get; set; } = string.Empty;
}
+22
View File
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Futonizer.Models;
/// <summary>
/// One record of a previously-run strip (or copy) job, persisted so it can
/// be reviewed later from Settings.
/// </summary>
public class HistoryEntry
{
public DateTime Timestamp { get; set; }
public string FileName { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
[JsonIgnore]
public bool Failed => !Success;
[JsonIgnore]
public string TimestampDisplay => Timestamp.ToString("yyyy-MM-dd HH:mm");
}
+47
View File
@@ -0,0 +1,47 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// Represents one currently-running strip job for display in the "active jobs" list,
/// since multiple files can be stripped concurrently.
/// </summary>
public class JobProgressItem : INotifyPropertyChanged
{
private int _percent;
private double _megabytesPerSecond;
public string FileName { get; set; } = string.Empty;
public int Percent
{
get => _percent;
set
{
_percent = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Display));
}
}
public double MegabytesPerSecond
{
get => _megabytesPerSecond;
set
{
_megabytesPerSecond = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Display));
}
}
public string Display => $"{FileName} - {Percent}% - {MegabytesPerSecond:F1} MB/s";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace Futonizer.Models;
/// <summary>
/// Root object returned by `mkvmerge -J <file>`.
/// Only the fields we actually need are mapped.
/// </summary>
public class MkvIdentifyResult
{
[JsonPropertyName("file_name")]
public string? FileName { get; set; }
[JsonPropertyName("tracks")]
public List<MkvTrack> Tracks { get; set; } = new();
}
public class MkvTrack
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("codec")]
public string Codec { get; set; } = string.Empty;
[JsonPropertyName("properties")]
public MkvTrackProperties? Properties { get; set; }
}
public class MkvTrackProperties
{
[JsonPropertyName("language")]
public string? Language { get; set; }
[JsonPropertyName("language_ietf")]
public string? LanguageIetf { get; set; }
[JsonPropertyName("track_name")]
public string? TrackName { get; set; }
[JsonPropertyName("default_track")]
public bool DefaultTrack { get; set; }
[JsonPropertyName("forced_track")]
public bool ForcedTrack { get; set; }
[JsonPropertyName("audio_channels")]
public int? AudioChannels { get; set; }
}
+118
View File
@@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// The four states shown as an icon in the file queue list.
/// </summary>
public enum FileLoadState
{
/// <summary>Currently being scanned with mkvmerge -J.</summary>
Loading,
/// <summary>Scan finished (or failed) but track selection isn't evaluated as correct/incorrect yet.</summary>
Loaded,
/// <summary>Exactly one audio track and one subtitle track are selected to be kept.</summary>
Correct,
/// <summary>Anything other than exactly one audio + one subtitle selected.</summary>
Incorrect,
}
/// <summary>
/// Represents one MKV file in the processing queue.
/// </summary>
public class QueuedFileItem : INotifyPropertyChanged
{
private string _status = string.Empty;
private bool _isScanning;
private bool _hasError;
public string FilePath { get; }
public string FileName => Path.GetFileName(FilePath);
public ObservableCollection<TrackItem> Tracks { get; } = new();
public string Status
{
get => _status;
set { _status = value; OnPropertyChanged(); }
}
public bool IsScanning
{
get => _isScanning;
set { _isScanning = value; OnPropertyChanged(); NotifyLoadState(); }
}
public bool HasError
{
get => _hasError;
set { _hasError = value; OnPropertyChanged(); NotifyLoadState(); }
}
public FileLoadState LoadState
{
get
{
if (_isScanning) return FileLoadState.Loading;
if (_hasError || Tracks.Count == 0) return FileLoadState.Loaded;
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy);
return audioSelected == 1 && subtitleSelected == 1
? FileLoadState.Correct
: FileLoadState.Incorrect;
}
}
public bool IsLoading => LoadState == FileLoadState.Loading;
public bool IsLoadedNeutral => LoadState == FileLoadState.Loaded;
public bool IsCorrect => LoadState == FileLoadState.Correct;
public bool IsIncorrect => LoadState == FileLoadState.Incorrect;
public QueuedFileItem(string filePath)
{
FilePath = filePath;
Tracks.CollectionChanged += OnTracksChanged;
}
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
{
foreach (TrackItem t in e.NewItems)
t.PropertyChanged += TrackPropertyChanged;
}
if (e.OldItems != null)
{
foreach (TrackItem t in e.OldItems)
t.PropertyChanged -= TrackPropertyChanged;
}
NotifyLoadState();
}
private void TrackPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TrackItem.Copy))
NotifyLoadState();
}
private void NotifyLoadState()
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LoadState)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoadedNeutral)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsCorrect)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsIncorrect)));
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+70
View File
@@ -0,0 +1,70 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// Represents one track of a queued MKV file, as shown in the track table.
/// The <see cref="Copy"/> property is user-editable and drives which tracks
/// are passed to mkvmerge when stripping.
/// </summary>
public class TrackItem : INotifyPropertyChanged
{
private bool _copy;
public int Id { get; init; }
public string Type { get; init; } = string.Empty;
public string Codec { get; init; } = string.Empty;
public string Language { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public bool ForcedDisplay { get; init; }
private bool _defaultTrack;
/// <summary>
/// Whether this track should be flagged as the default track of its type
/// when stripped. For subtitles this is kept in sync with <see cref="Copy"/>
/// (selecting a subtitle also makes it the default one).
/// </summary>
public bool DefaultTrack
{
get => _defaultTrack;
set
{
if (_defaultTrack == value) return;
_defaultTrack = value;
OnPropertyChanged();
}
}
/// <summary>
/// Text shown in the track table's Name column. Subtitle tracks are shown
/// as "Codec/Name" so tracks that share a display name but use a different
/// codec are easy to tell apart.
/// </summary>
public string DisplayName => Type == "subtitles" ? $"{Codec}/{Name}" : Name;
public bool Copy
{
get => _copy;
set
{
if (_copy == value) return;
_copy = value;
OnPropertyChanged();
}
}
/// <summary>
/// Key used to match subtitle tracks with the same identity across different
/// queued files when propagating a subtitle selection. Subtitles are matched
/// on "Codec/Name" so tracks with the same name but a different codec are
/// treated as distinct.
/// </summary>
public string PropagationKey => DisplayName;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}