7 Commits
Author SHA1 Message Date
l4kr c7c125d40b Match and mux in dropped external subtitles
Drag in subtitle files alongside .mkv files and they're matched to
the right video by episode code (S04E06, zero-padding and separator
insensitive, plus 4x06 as a fallback) and muxed in as extra subtitle
tracks. Any subtitle format mkvmerge accepts is recognized: SRT,
(S)SA, VobSub, WebVTT, PGS/SUP, USF, SAMI, TTML/DFXP, STL, Kate.

Matching works regardless of drop order — subtitles dropped before
their video are held pending until a matching video shows up, and
vice versa. Language, forced, and SDH flags are guessed from common
filename tokens (e.g. "eng", "forced", "sdh") to set the muxed
track's language/name/flags; unmatched subtitles are reported in the
status bar.
2026-07-08 10:37:56 +02:00
l4kr 304d9ad047 Bump version to 1.0.5
Handle [TAG] NN episode-only filenames: when a file already
carries a bracket tag prefix followed directly by an episode
number (e.g. [WBDP] 03 - Deus Vult), inject the show name
from the parent folder between the tag and the episode part.

Also update ExtractShowName to handle bracket-style folder
names (text before the first [ is the show name).
2026-07-07 14:15:43 +02:00
l4kr 11a519d309 Ignore publish/ build output 2026-07-05 17:14:16 +02:00
l4kr 8b94fefe0f Bump version to 1.0.4
Extract in-filename suffix release tag (e.g. -DemiHuman) and
move it to a [DemiHuman] bracket prefix, stripping it from the
filename. File-level tag takes priority over folder-derived tag.
2026-07-05 17:14:08 +02:00
l4kr 02ce7bb872 Bump version to 1.0.3
- Suffix release tag support: folder name ending in -TAG
  prepends [TAG] to output filenames
- Show name injection from parent folder for episode-only
  filenames (e.g. S01E01-Name.mkv)
- Queue and logs display predicted output filename
- Run button label reflects rename when applicable
- Gray out main UI while strip/rename is in progress
2026-07-05 16:42:55 +02:00
l4kr e61cb5e709 fixed space to play 2026-07-04 12:08:56 +02:00
l4kr a322b6bb00 fixed shit with the renames 2026-07-04 12:01:13 +02:00
11 changed files with 750 additions and 66 deletions
+1
View File
@@ -1,5 +1,6 @@
bin/
obj/
publish/
*.user
history.json
settings.json
+2 -2
View File
@@ -9,8 +9,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace>
<Version>1.0.2</Version>
<FileVersion>1.0.2.0</FileVersion>
<Version>1.0.6</Version>
<FileVersion>1.0.6.0</FileVersion>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup>
+74 -4
View File
@@ -137,7 +137,7 @@
<Grid Grid.Row="1">
<TextBlock x:Name="DropHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Drop .mkv files&#10;onto this window"
Text="Drop .mkv files (and subtitles)&#10;onto this window"
Opacity="0.4" FontSize="14" TextAlignment="Center"
IsHitTestVisible="False"/>
@@ -158,6 +158,7 @@
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="18"/>
</Grid.ColumnDefinitions>
@@ -175,16 +176,25 @@
Foreground="#F44336" VerticalAlignment="Center"
Visibility="{Binding IsIncorrect, Converter={StaticResource BoolToVis}}"/>
<TextBlock Grid.Column="1" Text="{Binding FileName}"
<TextBlock Grid.Column="1" Text="{Binding DisplayFileName}"
FontSize="12" VerticalAlignment="Center" Margin="6,0"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="2" Text="{Binding FileSizeDisplay}"
<Border Grid.Column="2" CornerRadius="8" Padding="5,1" Margin="2,0"
VerticalAlignment="Center"
Background="{DynamicResource AccentFillColorSecondaryBrush}"
Visibility="{Binding HasExternalSubtitles, Converter={StaticResource BoolToVis}}"
ToolTip="{Binding ExternalSubtitlesTooltip}">
<TextBlock Text="{Binding ExternalSubtitles.Count, StringFormat={}+{0} sub}"
FontSize="9" FontWeight="SemiBold"/>
</Border>
<TextBlock Grid.Column="3" Text="{Binding FileSizeDisplay}"
FontSize="11" VerticalAlignment="Center" Margin="4,0"
Opacity="0.6"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
<Button Grid.Column="3" Content="&#xD7;"
<Button Grid.Column="4" Content="&#xD7;"
Tag="{Binding}"
Click="RemoveFile_Click"
Background="Transparent" BorderThickness="0"
@@ -209,6 +219,55 @@
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- External subtitles matched to the selected file -->
<Border x:Name="ExternalSubsPanel" Grid.Row="0" Margin="10,8,10,0"
Padding="8,6" CornerRadius="6" Visibility="Collapsed"
Background="{DynamicResource ControlFillColorSecondaryBrush}">
<StackPanel>
<TextBlock Text="External subtitles to mux in" FontSize="11"
FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
<ItemsControl x:Name="ExternalSubsList">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1" CornerRadius="4"
Margin="0,0,6,6" Padding="6,3">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding LanguageDisplay}" FontSize="11"
FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding FileName}" FontSize="11" Opacity="0.6"
Margin="6,0,0,0" VerticalAlignment="Center"
MaxWidth="220" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="DEFAULT" FontSize="9" FontWeight="Bold"
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
Margin="6,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsDefault, Converter={StaticResource BoolToVis}}"/>
<Button Content="&#xD7;" Tag="{Binding}"
Click="RemoveExternalSubtitle_Click"
Background="Transparent" BorderThickness="0"
Padding="6,0,0,0" Cursor="Hand" FontSize="13"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Grid Grid.Row="1">
<TextBlock x:Name="NoSelectionHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Select a file to view its tracks"
@@ -230,6 +289,7 @@
GridLinesVisibility="None"
VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
PreviewKeyDown="TrackGrid_PreviewKeyDown"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<DataGrid.RowStyle>
@@ -328,8 +388,18 @@
</DataGrid.Columns>
</DataGrid>
</Grid>
</Grid>
</Border>
<!-- Processing overlay: dims the entire content area while stripping is running -->
<Border x:Name="ProcessingOverlay"
Grid.ColumnSpan="3"
Background="#99000000"
CornerRadius="8"
Visibility="Collapsed"
IsHitTestVisible="True"
Panel.ZIndex="10"/>
<!-- Loading overlay: blocks interaction until every queued file has finished loading -->
<Border x:Name="LoadingOverlay"
Grid.ColumnSpan="3"
+165 -50
View File
@@ -2,7 +2,6 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
@@ -21,6 +20,7 @@ public partial class MainWindow : FluentWindow
private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly List<string> _pendingSubtitles = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing;
private bool _propagatingSelection;
@@ -78,42 +78,6 @@ public partial class MainWindow : FluentWindow
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// If the file's parent folder has a bracketed prefix (e.g. "[neoHEVC] ")
/// and the file itself doesn't, renames the file on disk to include that
/// prefix and returns the new path. Returns the original path unchanged if
/// the condition isn't met or the rename fails.
/// </summary>
private static string TryApplyFolderPrefix(string filePath)
{
string? folder = Path.GetDirectoryName(filePath);
if (string.IsNullOrEmpty(folder)) return filePath;
string folderName = Path.GetFileName(folder);
var match = Regex.Match(folderName, @"^\[.+?\] ");
if (!match.Success) return filePath;
string prefix = match.Value;
string fileName = Path.GetFileName(filePath);
if (fileName.StartsWith(prefix, StringComparison.Ordinal)) return filePath;
string newFileName = prefix + fileName;
string newFilePath = Path.Combine(folder, newFileName);
if (File.Exists(newFilePath)) return filePath;
try
{
File.Move(filePath, newFilePath);
return newFilePath;
}
catch
{
return filePath;
}
}
private static void RestartApplication()
{
try
@@ -168,6 +132,15 @@ public partial class MainWindow : FluentWindow
RunButton.ToolTip = allCorrect
? null
: "Every file in the queue needs exactly one audio and one subtitle track selected before stripping.";
UpdateRunButtonLabel();
}
private void UpdateRunButtonLabel()
{
bool anyRename = _fileQueue.Any(f =>
!string.Equals(f.DisplayFileName, f.FileName, StringComparison.Ordinal));
RunButton.Content = anyRename ? "Rename and Strip Tracks" : "Strip Tracks";
}
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -224,10 +197,9 @@ public partial class MainWindow : FluentWindow
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasMkv = paths.Any(p =>
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
&& File.Exists(p));
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
bool hasAcceptedFile = paths.Any(p =>
File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
}
else
{
@@ -236,6 +208,9 @@ public partial class MainWindow : FluentWindow
e.Handled = true;
}
private static bool IsMkvFile(string path)
=> string.Equals(Path.GetExtension(path), ".mkv", StringComparison.OrdinalIgnoreCase);
private void Window_Drop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
@@ -243,31 +218,158 @@ public partial class MainWindow : FluentWindow
Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
var newFiles = paths
.Where(p => string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
var newVideoPaths = paths
.Where(p => IsMkvFile(p)
&& File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList();
var addedPaths = new List<string>();
foreach (var path in newFiles)
var subtitlePaths = paths
.Where(p => SubtitleMatcher.IsSubtitleFile(p) && File.Exists(p))
.ToList();
var newItems = new List<QueuedFileItem>();
foreach (var path in newVideoPaths)
{
string effectivePath = TryApplyFolderPrefix(path);
var item = new QueuedFileItem(effectivePath);
var item = new QueuedFileItem(path);
item.PropertyChanged += QueuedFileItem_PropertyChanged;
_fileQueue.Add(item);
newItems.Add(item);
_ = ScanFileItemAsync(item);
addedPaths.Add(effectivePath);
}
// Newly-added videos might satisfy subtitles dropped in earlier (or
// in this same batch, if the subtitle sorts before its video).
foreach (var item in newItems)
MatchPendingSubtitlesToFile(item);
int matchedSubtitles = 0;
foreach (var subPath in subtitlePaths)
{
if (MatchSubtitleToQueue(subPath))
matchedSubtitles++;
else if (!_pendingSubtitles.Contains(subPath, StringComparer.OrdinalIgnoreCase))
_pendingSubtitles.Add(subPath);
}
UpdateLoadingOverlay();
if (addedPaths.Count > 0)
if (newItems.Count > 0)
{
var first = _fileQueue.FirstOrDefault(q =>
string.Equals(q.FilePath, addedPaths[0], StringComparison.OrdinalIgnoreCase));
string.Equals(q.FilePath, newVideoPaths[0], StringComparison.OrdinalIgnoreCase));
if (first != null) FileQueueList.SelectedItem = first;
}
int unmatchedSubtitles = subtitlePaths.Count - matchedSubtitles;
if (matchedSubtitles > 0 || unmatchedSubtitles > 0)
{
StatusText.Text = unmatchedSubtitles > 0
? $"Matched {matchedSubtitles} subtitle(s); {unmatchedSubtitles} couldn't be matched to an episode code (S04E06 style)."
: $"Matched {matchedSubtitles} subtitle(s) to their episode.";
}
}
// ── Subtitle matching ───────────────────────────────────────────────────
/// <summary>
/// Attempts to attach a dropped subtitle file to whichever queued video(s)
/// share the same episode code (e.g. S04E06). Falls back to the single
/// queued file when there's exactly one and no episode code could be
/// extracted from the subtitle's filename. Returns false if no target
/// could be determined, in which case the file is kept pending until a
/// matching video is dropped later.
/// </summary>
private bool MatchSubtitleToQueue(string subtitlePath)
{
string subFileName = Path.GetFileName(subtitlePath);
string? code = SubtitleMatcher.ExtractEpisodeCode(subFileName);
List<QueuedFileItem> targets;
if (code != null)
{
targets = _fileQueue
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), code, StringComparison.OrdinalIgnoreCase))
.ToList();
}
else if (_fileQueue.Count == 1)
{
targets = new List<QueuedFileItem> { _fileQueue[0] };
}
else
{
targets = new List<QueuedFileItem>();
}
if (targets.Count == 0) return false;
foreach (var target in targets)
AttachExternalSubtitle(target, subtitlePath);
return true;
}
/// <summary>
/// Called whenever a new video is added to the queue: checks the pending
/// (previously unmatched) subtitle files for one sharing this video's
/// episode code, and attaches + removes any matches from the pending list.
/// </summary>
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
{
if (_pendingSubtitles.Count == 0) return;
string? code = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
if (code == null) return;
var matches = _pendingSubtitles
.Where(p => string.Equals(SubtitleMatcher.ExtractEpisodeCode(Path.GetFileName(p)), code, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var match in matches)
{
AttachExternalSubtitle(item, match);
_pendingSubtitles.Remove(match);
}
}
private static void AttachExternalSubtitle(QueuedFileItem target, string subtitlePath)
{
if (target.ExternalSubtitles.Any(s => string.Equals(s.FilePath, subtitlePath, StringComparison.OrdinalIgnoreCase)))
return;
var info = SubtitleLanguageHelper.Detect(Path.GetFileName(subtitlePath));
var sub = new ExternalSubtitleTrack(subtitlePath)
{
Language = info.Language,
TrackName = info.TrackName,
Forced = info.Forced,
HearingImpaired = info.HearingImpaired,
IsDefault = target.ExternalSubtitles.Count == 0,
};
target.ExternalSubtitles.Add(sub);
}
private void RemoveExternalSubtitle_Click(object sender, RoutedEventArgs e)
{
if (sender is not System.Windows.Controls.Button btn || btn.Tag is not ExternalSubtitleTrack sub) return;
var owner = _fileQueue.FirstOrDefault(f => f.ExternalSubtitles.Contains(sub));
if (owner == null) return;
bool wasDefault = sub.IsDefault;
owner.ExternalSubtitles.Remove(sub);
if (wasDefault)
{
var next = owner.ExternalSubtitles.FirstOrDefault();
if (next != null) next.IsDefault = true;
}
if (ReferenceEquals(FileQueueList.SelectedItem, owner))
ExternalSubsPanel.Visibility = owner.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
e.Handled = true;
}
// ── File scanning ─────────────────────────────────────────────────────────
@@ -443,6 +545,13 @@ public partial class MainWindow : FluentWindow
/// Windows has associated with .mkv files (typically the default video
/// player), so the user can quickly preview a file without leaving the app.
/// </summary>
private void TrackGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Space) return;
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
}
private void OpenSelectedFileInDefaultPlayer()
{
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
@@ -481,12 +590,16 @@ public partial class MainWindow : FluentWindow
TrackGrid.ItemsSource = file.Tracks;
TrackGrid.Visibility = Visibility.Visible;
NoSelectionHint.Visibility = Visibility.Collapsed;
ExternalSubsList.ItemsSource = file.ExternalSubtitles;
ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
}
else
{
TrackGrid.ItemsSource = null;
TrackGrid.Visibility = Visibility.Collapsed;
NoSelectionHint.Visibility = Visibility.Visible;
ExternalSubsList.ItemsSource = null;
ExternalSubsPanel.Visibility = Visibility.Collapsed;
}
}
@@ -533,6 +646,7 @@ public partial class MainWindow : FluentWindow
_isProcessing = true;
RunButton.IsEnabled = false;
StatusText.Text = string.Empty;
ProcessingOverlay.Visibility = Visibility.Visible;
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
{
@@ -541,6 +655,7 @@ public partial class MainWindow : FluentWindow
progressWin.Closed += (_, _) =>
{
_isProcessing = false;
ProcessingOverlay.Visibility = Visibility.Collapsed;
if (progressWin.ShouldClearQueue)
{
foreach (var item in _fileQueue)
+58
View File
@@ -0,0 +1,58 @@
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
namespace Futonizer.Models;
/// <summary>
/// Represents one external subtitle file that was drag-and-dropped in and
/// matched to a <see cref="QueuedFileItem"/> by episode code (e.g. S04E06),
/// to be muxed in as an additional subtitle track when the file is processed.
/// </summary>
public class ExternalSubtitleTrack : INotifyPropertyChanged
{
private bool _isDefault;
public string FilePath { get; }
public string FileName => Path.GetFileName(FilePath);
/// <summary>Uppercased extension without the dot, e.g. "SRT", "ASS", "SUP".</summary>
public string FormatDisplay => Path.GetExtension(FilePath).TrimStart('.').ToUpperInvariant();
/// <summary>ISO 639-2 language code detected from the filename, or "und" if unknown.</summary>
public string Language { get; init; } = "und";
/// <summary>Track name to embed in the output file; may be empty.</summary>
public string TrackName { get; init; } = string.Empty;
public bool Forced { get; init; }
public bool HearingImpaired { get; init; }
/// <summary>
/// Whether this is the subtitle track flagged default in the output.
/// Exactly one external subtitle per file should have this set at a time.
/// </summary>
public bool IsDefault
{
get => _isDefault;
set
{
if (_isDefault == value) return;
_isDefault = value;
OnPropertyChanged();
}
}
/// <summary>Short "FORMAT · LANG" label shown as a chip in the UI.</summary>
public string LanguageDisplay => $"{FormatDisplay} · {(string.Equals(Language, "und", StringComparison.OrdinalIgnoreCase) ? "UND" : Language.ToUpperInvariant())}";
public ExternalSubtitleTrack(string filePath)
{
FilePath = filePath;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+35 -1
View File
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using Futonizer.Services;
namespace Futonizer.Models;
@@ -32,9 +33,29 @@ public class QueuedFileItem : INotifyPropertyChanged
public string FilePath { get; }
public string FileName => Path.GetFileName(FilePath);
/// <summary>
/// The filename as it will appear after rename adjustments (release-tag prefix
/// and/or show-name injection). Equals <see cref="FileName"/> when no
/// changes would be applied.
/// </summary>
public string DisplayFileName => FileNameHelper.ComputeOutputFileName(FilePath);
public string FileSizeDisplay { get; }
public ObservableCollection<TrackItem> Tracks { get; } = new();
/// <summary>
/// External subtitle files (dropped separately and matched to this file by
/// episode code, e.g. S04E06) that will be muxed in as additional
/// subtitle tracks when this file is processed.
/// </summary>
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
public bool HasExternalSubtitles => ExternalSubtitles.Count > 0;
public string ExternalSubtitlesTooltip => ExternalSubtitles.Count == 0
? string.Empty
: "External subtitles to mux in:\n" + string.Join("\n", ExternalSubtitles.Select(s => $"- {s.FileName} ({s.LanguageDisplay})"));
public string Status
{
get => _status;
@@ -63,7 +84,12 @@ public class QueuedFileItem : INotifyPropertyChanged
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
// A subtitle requirement is met either by picking exactly one
// internal track, or by having at least one matched external
// subtitle file (which can also be combined with one internal track).
bool subtitleOk = subtitleSelected == 1 || (subtitleSelected == 0 && ExternalSubtitles.Count > 0);
return audioSelected == 1 && subtitleOk
? FileLoadState.Correct
: FileLoadState.Incorrect;
}
@@ -79,6 +105,7 @@ public class QueuedFileItem : INotifyPropertyChanged
FilePath = filePath;
FileSizeDisplay = FormatFileSize(filePath);
Tracks.CollectionChanged += OnTracksChanged;
ExternalSubtitles.CollectionChanged += OnExternalSubtitlesChanged;
}
private static string FormatFileSize(string filePath)
@@ -104,6 +131,13 @@ public class QueuedFileItem : INotifyPropertyChanged
}
}
private void OnExternalSubtitlesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(HasExternalSubtitles));
OnPropertyChanged(nameof(ExternalSubtitlesTooltip));
NotifyLoadState();
}
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
+194
View File
@@ -0,0 +1,194 @@
using System.IO;
using System.Text.RegularExpressions;
namespace Futonizer.Services;
/// <summary>
/// Pure static helpers for computing the final output filename from a source
/// file path, applying release-tag prefixing and show-name injection.
/// </summary>
public static class FileNameHelper
{
// Folder suffix release tag: "Show Name-TTGA" → tag = "TTGA"
// All-uppercase, 2-8 chars, at end of folder name.
private static readonly Regex FolderSuffixTagRegex =
new(@"-([A-Z][A-Z0-9]{1,7})$", RegexOptions.Compiled);
// File suffix release tag: "…HEVC-DemiHuman" → tag = "DemiHuman"
// Mixed case, 3-21 chars (no dots/spaces), at end of filename stem.
// Min 3 chars avoids false-positives like "-v2" or "-HD".
private static readonly Regex FileSuffixTagRegex =
new(@"-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled);
// Prefix release tag: "[KAA] Show Name" → tag = "KAA"
private static readonly Regex PrefixTagRegex =
new(@"^\[(.+?)\]", RegexOptions.Compiled);
// Episode-only filename (no tag prefix): starts with S01E01, S1E1, etc.
private static readonly Regex EpisodeOnlyRegex =
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Episode start after a tag prefix is stripped: plain number OR S01E01 style.
// e.g. "03 - Deus Vult ..." or "S01E01-Deep Blue"
private static readonly Regex EpisodeStartRegex =
new(@"^(\d+|S\d+E\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Individual metadata-token patterns used to find where the show name ends
// inside a folder name.
private static readonly Regex[] MetadataPatterns =
[
new(@"^S\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01E01...
new(@"^E[Pp]?\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // E01, EP01
new(@"^\d{3,4}[pP]$", RegexOptions.Compiled), // 1080p, 720p
new(@"^(4K|UHD|2160p)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // 4K, UHD
new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources
new(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs
new(@"^(FLAC|AAC|DTS|TrueHD|Atmos|AC3|EAC3|Opus|MP3)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs
new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
];
// -------------------------------------------------------------------------
/// <summary>
/// Computes the final output filename for a given source file path, applying
/// (in order):
/// <list type="number">
/// <item>Filename suffix tag extraction — a trailing <c>-GroupName</c> on
/// the filename stem is stripped and promoted to a bracket prefix.
/// Takes priority over any folder-derived tag.</item>
/// <item>Show-name injection — if the (now tag-free) filename starts with
/// an episode code (e.g. S01E01) the show name is derived from the
/// parent folder and prepended.</item>
/// <item>Release-tag prefix — a <c>[TAG]</c> bracket prefix, sourced from
/// the filename suffix, the folder suffix, or the folder prefix,
/// in that priority order.</item>
/// </list>
/// Returns the original filename unchanged when no transformations apply.
/// </summary>
public static string ComputeOutputFileName(string sourceFilePath)
{
string fileName = Path.GetFileName(sourceFilePath);
string ext = Path.GetExtension(fileName);
string stemNoExt = Path.GetFileNameWithoutExtension(fileName);
string? folder = Path.GetDirectoryName(sourceFilePath);
string folderName = string.IsNullOrEmpty(folder) ? "" : (Path.GetFileName(folder) ?? "");
// 1. Extract in-filename suffix tag and remove it from the stem.
// e.g. "...HEVC-DemiHuman" → tag="DemiHuman", stem="...HEVC"
string? fileTag = null;
var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt);
if (fileSuffixMatch.Success)
{
fileTag = fileSuffixMatch.Groups[1].Value;
stemNoExt = stemNoExt[..fileSuffixMatch.Index];
fileName = stemNoExt + ext;
}
// 2. Determine the release tag: filename suffix > folder tag.
string? tag = fileTag;
if (string.IsNullOrEmpty(tag) && !string.IsNullOrEmpty(folderName))
tag = ExtractReleaseTag(folderName);
// 3. Check for episode-only patterns and inject show name if needed.
if (!string.IsNullOrEmpty(folderName))
{
// Pattern A: untagged stem starts with S01E01 (e.g. "S01E01-Deep Blue.mkv")
if (EpisodeOnlyRegex.IsMatch(stemNoExt))
{
string showName = ExtractShowName(folderName);
if (!string.IsNullOrEmpty(showName))
fileName = showName + " " + fileName;
}
// Pattern B: file already carries [TAG] but episode number follows directly.
// e.g. "[WBDP] 03 - Deus Vult ..." → insert show name after the tag.
else if (!string.IsNullOrEmpty(tag))
{
string tagPfx = $"[{tag}] ";
if (stemNoExt.StartsWith(tagPfx, StringComparison.OrdinalIgnoreCase))
{
string afterTag = stemNoExt[tagPfx.Length..];
if (EpisodeStartRegex.IsMatch(afterTag))
{
string showName = ExtractShowName(folderName);
if (!string.IsNullOrEmpty(showName))
{
// Strip the tag, inject show name; tag is re-prepended in step 4.
fileName = showName + " " + fileName[tagPfx.Length..];
}
}
}
}
}
// 4. Prepend release tag if not already present.
if (!string.IsNullOrEmpty(tag))
{
string tagPrefix = $"[{tag}]";
if (!fileName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase))
fileName = tagPrefix + " " + fileName;
}
return fileName;
}
/// <summary>
/// Extracts the release tag string (without brackets) from a folder name.
/// Suffix pattern (<c>-TAG</c>) takes priority over prefix pattern
/// (<c>[TAG]</c>). Returns <c>null</c> when neither is present.
/// </summary>
public static string? ExtractReleaseTag(string folderName)
{
var suffixMatch = FolderSuffixTagRegex.Match(folderName);
if (suffixMatch.Success) return suffixMatch.Groups[1].Value;
var prefixMatch = PrefixTagRegex.Match(folderName);
if (prefixMatch.Success) return prefixMatch.Groups[1].Value;
return null;
}
/// <summary>
/// Derives the show name from a folder name by stripping any release tag
/// (prefix or suffix) and then:
/// <list type="bullet">
/// <item>If the remainder contains brackets (e.g. <c>[BD][1080p-FLAC]</c>),
/// returns the text that precedes the first bracket.</item>
/// <item>Otherwise splits on spaces and collects words up to the first
/// recognised metadata token (season, quality, source, codec, etc.).</item>
/// </list>
/// </summary>
public static string ExtractShowName(string folderName)
{
// Strip release tags first.
string name = FolderSuffixTagRegex.Replace(folderName, "").Trim();
name = PrefixTagRegex.Replace(name, "").Trim();
// Nothing left, or what remains immediately starts with a bracket — no show name.
if (string.IsNullOrEmpty(name) || name[0] == '[') return string.Empty;
// Bracket-style metadata: "The Saga of Tanya the Evil [BD][1080p-FLAC][HEVC]"
int bracketIdx = name.IndexOf('[');
if (bracketIdx > 0)
return name[..bracketIdx].Trim();
// Space-separated metadata: "Grand Blue Dreaming S01 1080p BD Remux FLAC"
var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var showWords = new List<string>();
foreach (var word in words)
{
if (IsMetadataToken(word)) break;
showWords.Add(word);
}
return string.Join(" ", showWords).Trim();
}
private static bool IsMetadataToken(string token)
{
foreach (var pattern in MetadataPatterns)
if (pattern.IsMatch(token)) return true;
return false;
}
}
+43 -4
View File
@@ -258,12 +258,13 @@ public class MkvService
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,
videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
progress, ct);
}
@@ -281,6 +282,7 @@ public class MkvService
null, // null = keep all video
scan.AudioKeepIds,
scan.SubtitleKeepIds,
null,
progress, ct);
}
@@ -386,6 +388,7 @@ public class MkvService
IReadOnlyList<int>? videoKeepIds,
IReadOnlyList<int>? audioKeepIds,
IReadOnlyList<int>? subtitleKeepIds,
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles,
IProgress<ProcessingProgress>? progress,
CancellationToken ct)
{
@@ -422,16 +425,52 @@ public class MkvService
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.
// 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($"{subtitleKeepIds[0]}:yes");
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();
+105
View File
@@ -0,0 +1,105 @@
using System.IO;
namespace Futonizer.Services;
/// <summary>
/// Result of scanning an external subtitle's filename for language and
/// display hints.
/// </summary>
public readonly record struct SubtitleLanguageInfo(string Language, bool Forced, bool HearingImpaired, string TrackName);
/// <summary>
/// Best-effort language/flag detection for external subtitle files, based on
/// common filename tokens (e.g. "Show.S04E06.eng.srt", "Show.S04E06.forced.srt").
/// Never throws — falls back to an "und" (undetermined) language with no
/// track name when nothing recognisable is found.
/// </summary>
public static class SubtitleLanguageHelper
{
private static readonly Dictionary<string, string> TokenToIso6392 = new(StringComparer.OrdinalIgnoreCase)
{
["en"] = "eng", ["eng"] = "eng", ["english"] = "eng",
["ja"] = "jpn", ["jp"] = "jpn", ["jpn"] = "jpn", ["japanese"] = "jpn",
["es"] = "spa", ["spa"] = "spa", ["spanish"] = "spa", ["esp"] = "spa",
["fr"] = "fre", ["fre"] = "fre", ["fra"] = "fre", ["french"] = "fre",
["de"] = "ger", ["ger"] = "ger", ["deu"] = "ger", ["german"] = "ger",
["it"] = "ita", ["ita"] = "ita", ["italian"] = "ita",
["pt"] = "por", ["por"] = "por", ["portuguese"] = "por", ["ptbr"] = "por",
["ru"] = "rus", ["rus"] = "rus", ["russian"] = "rus",
["zh"] = "chi", ["chi"] = "chi", ["zho"] = "chi", ["chinese"] = "chi", ["cmn"] = "chi", ["yue"] = "chi",
["ko"] = "kor", ["kor"] = "kor", ["korean"] = "kor",
["ar"] = "ara", ["ara"] = "ara", ["arabic"] = "ara",
["nl"] = "dut", ["dut"] = "dut", ["nld"] = "dut", ["dutch"] = "dut",
["sv"] = "swe", ["swe"] = "swe", ["swedish"] = "swe",
["no"] = "nor", ["nor"] = "nor", ["norwegian"] = "nor",
["da"] = "dan", ["dan"] = "dan", ["danish"] = "dan",
["fi"] = "fin", ["fin"] = "fin", ["finnish"] = "fin",
["pl"] = "pol", ["pol"] = "pol", ["polish"] = "pol",
["tr"] = "tur", ["tur"] = "tur", ["turkish"] = "tur",
["vi"] = "vie", ["vie"] = "vie", ["vietnamese"] = "vie",
["th"] = "tha", ["tha"] = "tha", ["thai"] = "tha",
["id"] = "ind", ["ind"] = "ind", ["indonesian"] = "ind",
["he"] = "heb", ["heb"] = "heb", ["hebrew"] = "heb",
["hin"] = "hin", ["hindi"] = "hin",
["cs"] = "cze", ["cze"] = "cze", ["ces"] = "cze", ["czech"] = "cze",
["el"] = "gre", ["gre"] = "gre", ["ell"] = "gre", ["greek"] = "gre",
["hu"] = "hun", ["hun"] = "hun", ["hungarian"] = "hun",
["ro"] = "rum", ["rum"] = "rum", ["ron"] = "rum", ["romanian"] = "rum",
["uk"] = "ukr", ["ukr"] = "ukr", ["ukrainian"] = "ukr",
};
private static readonly Dictionary<string, string> Iso6392ToDisplayName = new(StringComparer.OrdinalIgnoreCase)
{
["eng"] = "English", ["jpn"] = "Japanese", ["spa"] = "Spanish", ["fre"] = "French",
["ger"] = "German", ["ita"] = "Italian", ["por"] = "Portuguese", ["rus"] = "Russian",
["chi"] = "Chinese", ["kor"] = "Korean", ["ara"] = "Arabic", ["dut"] = "Dutch",
["swe"] = "Swedish", ["nor"] = "Norwegian", ["dan"] = "Danish", ["fin"] = "Finnish",
["pol"] = "Polish", ["tur"] = "Turkish", ["vie"] = "Vietnamese", ["tha"] = "Thai",
["ind"] = "Indonesian", ["heb"] = "Hebrew", ["hin"] = "Hindi", ["cze"] = "Czech",
["gre"] = "Greek", ["hun"] = "Hungarian", ["rum"] = "Romanian", ["ukr"] = "Ukrainian",
};
private static readonly char[] TokenSeparators = { '.', '_', '-', ' ', '(', ')', '[', ']' };
public static SubtitleLanguageInfo Detect(string fileName)
{
string stem = Path.GetFileNameWithoutExtension(fileName);
var tokens = stem.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries);
string language = "und";
bool forced = false;
bool hearingImpaired = false;
foreach (var token in tokens)
{
if (string.Equals(token, "forced", StringComparison.OrdinalIgnoreCase))
{
forced = true;
continue;
}
if (string.Equals(token, "sdh", StringComparison.OrdinalIgnoreCase)
|| string.Equals(token, "cc", StringComparison.OrdinalIgnoreCase))
{
hearingImpaired = true;
continue;
}
if (language == "und" && TokenToIso6392.TryGetValue(token, out var iso))
language = iso;
}
string trackName = string.Empty;
if (Iso6392ToDisplayName.TryGetValue(language, out var displayName))
trackName = displayName;
if (forced || hearingImpaired)
{
var suffixes = new List<string>();
if (forced) suffixes.Add("Forced");
if (hearingImpaired) suffixes.Add("SDH");
string suffix = $" ({string.Join(", ", suffixes)})";
trackName = string.IsNullOrEmpty(trackName) ? suffix.Trim(' ', '(', ')') : trackName + suffix;
}
return new SubtitleLanguageInfo(language, forced, hearingImpaired, trackName);
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.IO;
using System.Text.RegularExpressions;
namespace Futonizer.Services;
/// <summary>
/// Recognises subtitle files by extension and extracts a normalized episode
/// code (e.g. "S04E06") from a filename, used to match dropped subtitle
/// files to the video file with the same episode code.
/// </summary>
public static class SubtitleMatcher
{
/// <summary>
/// Every subtitle container/format extension mkvmerge (and therefore
/// Futonizer) can mux in: SubRip, (Advanced) SubStation Alpha, VobSub,
/// WebVTT, HDMV/PGS, Universal Subtitle Format, SAMI, TTML/DFXP, EBU-STL,
/// MicroDVD/SubViewer (both commonly saved with a plain ".sub" extension),
/// and Kate.
/// </summary>
public static readonly string[] SupportedExtensions =
{
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", ".webvtt",
".sup", ".pgs", ".usf", ".smi", ".sami", ".ttml", ".dfxp",
".stl", ".kate",
};
public static bool IsSubtitleFile(string path)
{
string ext = Path.GetExtension(path);
return !string.IsNullOrEmpty(ext)
&& SupportedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
}
// "S04E06", "S4E6", "S04 E06", "S04-E06", "S04.E06" — the standard scene/anime tagging scheme.
private static readonly Regex SeasonEpisodeRegex =
new(@"S(\d{1,4})[\s._-]*E(\d{1,4})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Fallback: "4x06" style season x episode separator.
private static readonly Regex SeasonXEpisodeRegex =
new(@"(?<![\d.])(\d{1,2})x(\d{2,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Extracts and normalizes the episode code from a filename (e.g.
/// "Show.S4E6.1080p.mkv" and "Show - 04x06 - Title.srt" both return
/// "S04E06"), so files can be matched regardless of zero-padding or
/// separator style. Returns null if no episode code is found.
/// </summary>
public static string? ExtractEpisodeCode(string fileName)
{
var match = SeasonEpisodeRegex.Match(fileName);
if (!match.Success)
match = SeasonXEpisodeRegex.Match(fileName);
if (!match.Success) return null;
if (!int.TryParse(match.Groups[1].Value, out int season)) return null;
if (!int.TryParse(match.Groups[2].Value, out int episode)) return null;
return $"S{season:D2}E{episode:D2}";
}
}
+12 -5
View File
@@ -144,7 +144,8 @@ public partial class ProgressWindow : FluentWindow
{
await semaphore.WaitAsync(ct);
var job = new JobProgressItem { FileName = file.FileName, FileSizeBytes = SafeFileLength(file.FilePath) };
string computedFileName = FileNameHelper.ComputeOutputFileName(file.FilePath);
var job = new JobProgressItem { FileName = computedFileName, FileSizeBytes = SafeFileLength(file.FilePath) };
job.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
@@ -157,9 +158,11 @@ public partial class ProgressWindow : FluentWindow
try
{
string sourceDir = Path.GetDirectoryName(file.FilePath) ?? "";
string outputFilePath = overwritingInPlace
? file.FilePath
: Path.Combine(_outputFolder, file.FileName);
? Path.Combine(sourceDir, computedFileName)
: Path.Combine(_outputFolder, computedFileName);
var videoIds = file.Tracks
.Where(t => t.Type == "video" && t.Copy)
@@ -171,7 +174,7 @@ public partial class ProgressWindow : FluentWindow
.Where(t => t.Type == "subtitles" && t.Copy)
.Select(t => t.Id).ToList();
bool nothingToStrip = file.Tracks.All(t => t.Copy);
bool nothingToStrip = file.Tracks.All(t => t.Copy) && file.ExternalSubtitles.Count == 0;
var progress = new Progress<ProcessingProgress>(p =>
{
@@ -184,10 +187,14 @@ public partial class ProgressWindow : FluentWindow
: await service.StripTracksAsync(
file.FilePath, outputFilePath,
videoIds, audioIds, subIds,
file.ExternalSubtitles.ToList(),
progress, ct);
string tag = result.Success ? "[DONE]" : "[FAIL]";
AppendLog($"{tag} {result.FileName} — {result.Message}");
string extInfo = file.ExternalSubtitles.Count > 0
? $" (+{file.ExternalSubtitles.Count} external sub{(file.ExternalSubtitles.Count == 1 ? "" : "s")})"
: string.Empty;
AppendLog($"{tag} {Path.GetFileName(result.OutputFilePath)}{extInfo} — {result.Message}");
HistoryService.Add(new HistoryEntry
{