17 Commits
Author SHA1 Message Date
l4kr 08aae68525 Detect release group after bracketed metadata suffix
Naming-bot style releases like:
  Family Guy (1999) - S22E15 - Faith No More [HULU WEBDL-1080p]
  [EAC3 5.1][h264]-NTb
tack the group on with a dash right after the last metadata bracket,
rather than as a bare word ("x264-Group") or inside a trailing
parenthetical. FileSuffixTagRegex only matched the bare-word case, so
this pattern fell through untouched.

Added FileSuffixBracketDashTagRegex to catch "[bracket]-Group" at the
end of the stem, guarded by IsMetadataBracket on the bracket contents
so an ordinary hyphenated word after an unrelated bracket (e.g.
"[Interview]-Style") is never mistaken for a group suffix.
2026-07-14 11:14:59 +02:00
l4kr 89bba70ee1 Replace spacebar-to-play with double-click
Opening the selected file's default player is now triggered by
double-clicking the file queue row or a track table row, instead of
pressing Space. The Delete key still removes selected queue items.

Double-clicks on the track table's Copy checkbox column are ignored
so rapidly toggling it doesn't also launch the player.
2026-07-14 11:03:40 +02:00
l4kr 3903fd475a Fix double-tagging when file already has a release prefix
Fansub descriptor brackets like [Multiple Subtitle] or [Dual Audio]
were being misread as release group names because their individual
words didn't match any known metadata pattern. This caused a bogus
tag to be stacked in front of files that already carried their own
[Group] prefix (e.g. Erai-raws releases).

- Recognize common multi-word fansub descriptors (Multi(ple) Sub(s),
  Dual/Multi Audio, Hard/Soft sub, Uncensored/Batch/Complete, etc.)
  as metadata via whole-phrase matching in IsMetadataBracket.
- Never prepend a derived release tag when the file's own filename
  already starts with a different bracket prefix, preventing any
  tag from being stacked on top of an existing one.
2026-07-14 08:33:11 +02:00
l4kr 1348cb5394 Recognize trailing [tag] on folder, guard metadata tags
Folders that carry their release tag as a bracket at the end
(rather than a -TAG suffix or [TAG] prefix), e.g.:
  Gravity Falls 2012  Season 2 Complete + EXTRAS 720p BluRay x264 [i_c]
now have that tag ([i_c]) prepended to the episode filename.

Also fixes a real bug where a title's own hyphenated word (e.g.
"Scary-oke") could be mistaken for a "-GroupName" suffix; the word
before the dash must now itself look like quality/source/codec
metadata for that pattern to fire.

Every tag-extraction path (file dash-suffix, file trailing-parens
group, folder dash-suffix, folder bracket-suffix, folder bracket-
prefix) now also refuses to promote something that's obviously a
metadata token itself (e.g. WEB, 1080p, HDR, WEBRip) to a release
tag.
2026-07-08 11:18:01 +02:00
l4kr 3efe246a20 Extract release group from trailing (...) metadata
Amazon/streaming rips like:
  Helluva Boss (2020) - S01E01 - Murder Family (1080p AMZN WEB-DL x265 Celdra)
tuck the release group in as the last word of the closing metadata
block instead of a "-Group" suffix or its own brackets. That trailing
word is now promoted to a [Group] prefix and stripped from the
parentheses, e.g.:
  [Celdra] Helluva Boss (2020) - S01E01 - Murder Family (1080p AMZN WEB-DL x265)

Only fires when the rest of the block already looks like quality
metadata (resolution/source/codec/etc.), so unrelated parentheticals
like "(2020)" or "(Dual Audio)" are left untouched.
2026-07-08 10:59:21 +02:00
l4kr 10d7c14444 Fall back to first audio track when none preferred
Auto-selection still prefers Japanese/Chinese/undefined audio, but
if a file has none of those (e.g. only English), the first audio
track is pre-checked instead of leaving no audio track selected.
2026-07-08 10:53:48 +02:00
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
l4kr 1909b7152a Bump version to 1.0.2 2026-07-04 11:50:16 +02:00
l4kr e7c5d4aca5 add prefix to files if they don't have it 2026-07-04 11:48:44 +02:00
l4kr d995dc3c48 Lower default strip concurrency to 3 2026-07-04 02:55:42 +02:00
l4kr 759d7e61d3 Ignore zip build artifacts 2026-07-04 02:37:24 +02:00
11 changed files with 909 additions and 35 deletions
+2
View File
@@ -1,5 +1,7 @@
bin/ bin/
obj/ obj/
publish/
*.user *.user
history.json history.json
settings.json settings.json
*.zip
+2
View File
@@ -9,6 +9,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName> <AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace> <RootNamespace>Futonizer</RootNamespace>
<Version>1.0.12</Version>
<FileVersion>1.0.12.0</FileVersion>
<ApplicationIcon>Assets\app.ico</ApplicationIcon> <ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
+75 -4
View File
@@ -137,7 +137,7 @@
<Grid Grid.Row="1"> <Grid Grid.Row="1">
<TextBlock x:Name="DropHint" <TextBlock x:Name="DropHint"
HorizontalAlignment="Center" VerticalAlignment="Center" 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" Opacity="0.4" FontSize="14" TextAlignment="Center"
IsHitTestVisible="False"/> IsHitTestVisible="False"/>
@@ -149,6 +149,7 @@
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel" PreviewMouseWheel="SmoothList_PreviewMouseWheel"
PreviewKeyDown="FileQueueList_PreviewKeyDown" PreviewKeyDown="FileQueueList_PreviewKeyDown"
MouseDoubleClick="FileQueueList_MouseDoubleClick"
ItemContainerStyle="{StaticResource QueueListBoxItemStyle}" ItemContainerStyle="{StaticResource QueueListBoxItemStyle}"
SelectionChanged="FileQueueList_SelectionChanged"> SelectionChanged="FileQueueList_SelectionChanged">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
@@ -158,6 +159,7 @@
<ColumnDefinition Width="16"/> <ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="18"/> <ColumnDefinition Width="18"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@@ -175,16 +177,25 @@
Foreground="#F44336" VerticalAlignment="Center" Foreground="#F44336" VerticalAlignment="Center"
Visibility="{Binding IsIncorrect, Converter={StaticResource BoolToVis}}"/> 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" FontSize="12" VerticalAlignment="Center" Margin="6,0"
TextTrimming="CharacterEllipsis"/> 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" FontSize="11" VerticalAlignment="Center" Margin="4,0"
Opacity="0.6" Opacity="0.6"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/> Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
<Button Grid.Column="3" Content="&#xD7;" <Button Grid.Column="4" Content="&#xD7;"
Tag="{Binding}" Tag="{Binding}"
Click="RemoveFile_Click" Click="RemoveFile_Click"
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
@@ -209,6 +220,55 @@
BorderBrush="{DynamicResource ControlElevationBorderBrush}" BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1"> BorderThickness="1">
<Grid> <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" <TextBlock x:Name="NoSelectionHint"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Select a file to view its tracks" Text="Select a file to view its tracks"
@@ -230,6 +290,7 @@
GridLinesVisibility="None" GridLinesVisibility="None"
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel" PreviewMouseWheel="SmoothList_PreviewMouseWheel"
MouseDoubleClick="TrackGrid_MouseDoubleClick"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"> ScrollViewer.VerticalScrollBarVisibility="Auto">
<DataGrid.RowStyle> <DataGrid.RowStyle>
@@ -327,9 +388,19 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</Grid>
</Grid> </Grid>
</Border> </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 --> <!-- Loading overlay: blocks interaction until every queued file has finished loading -->
<Border x:Name="LoadingOverlay" <Border x:Name="LoadingOverlay"
Grid.ColumnSpan="3" Grid.ColumnSpan="3"
+191 -17
View File
@@ -16,10 +16,11 @@ namespace Futonizer;
public partial class MainWindow : FluentWindow public partial class MainWindow : FluentWindow
{ {
private const int MaxConcurrentScans = 10; private const int MaxConcurrentScans = 10;
private const int MaxConcurrentStrips = 5; private const int MaxConcurrentStrips = 3;
private AppSettings _settings = new(); private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new(); private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly List<string> _pendingSubtitles = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans); private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing; private bool _isProcessing;
private bool _propagatingSelection; private bool _propagatingSelection;
@@ -131,6 +132,15 @@ public partial class MainWindow : FluentWindow
RunButton.ToolTip = allCorrect RunButton.ToolTip = allCorrect
? null ? null
: "Every file in the queue needs exactly one audio and one subtitle track selected before stripping."; : "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) private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -187,10 +197,9 @@ public partial class MainWindow : FluentWindow
if (e.Data.GetDataPresent(DataFormats.FileDrop)) if (e.Data.GetDataPresent(DataFormats.FileDrop))
{ {
var paths = (string[])e.Data.GetData(DataFormats.FileDrop); var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasMkv = paths.Any(p => bool hasAcceptedFile = paths.Any(p =>
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase) File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
&& File.Exists(p)); e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
} }
else else
{ {
@@ -199,6 +208,9 @@ public partial class MainWindow : FluentWindow
e.Handled = true; 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) private void Window_Drop(object sender, DragEventArgs e)
{ {
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
@@ -206,28 +218,158 @@ public partial class MainWindow : FluentWindow
Activate(); Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop); 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) && File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase))) && !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList(); .ToList();
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)
{ {
var item = new QueuedFileItem(path); var item = new QueuedFileItem(path);
item.PropertyChanged += QueuedFileItem_PropertyChanged; item.PropertyChanged += QueuedFileItem_PropertyChanged;
_fileQueue.Add(item); _fileQueue.Add(item);
newItems.Add(item);
_ = ScanFileItemAsync(item); _ = ScanFileItemAsync(item);
} }
// 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(); UpdateLoadingOverlay();
if (newFiles.Count > 0) if (newItems.Count > 0)
{ {
var first = _fileQueue.FirstOrDefault(q => var first = _fileQueue.FirstOrDefault(q =>
string.Equals(q.FilePath, newFiles[0], StringComparison.OrdinalIgnoreCase)); string.Equals(q.FilePath, newVideoPaths[0], StringComparison.OrdinalIgnoreCase));
if (first != null) FileQueueList.SelectedItem = first; 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 ───────────────────────────────────────────────────────── // ── File scanning ─────────────────────────────────────────────────────────
@@ -377,13 +519,6 @@ public partial class MainWindow : FluentWindow
/// </summary> /// </summary>
private void FileQueueList_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) private void FileQueueList_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{ {
if (e.Key == System.Windows.Input.Key.Space)
{
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
return;
}
if (e.Key != System.Windows.Input.Key.Delete) return; if (e.Key != System.Windows.Input.Key.Delete) return;
var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList(); var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList();
@@ -403,6 +538,39 @@ public partial class MainWindow : FluentWindow
/// Windows has associated with .mkv files (typically the default video /// Windows has associated with .mkv files (typically the default video
/// player), so the user can quickly preview a file without leaving the app. /// player), so the user can quickly preview a file without leaving the app.
/// </summary> /// </summary>
private void FileQueueList_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
}
/// <summary>
/// Same as <see cref="FileQueueList_MouseDoubleClick"/>, but for the track
/// table. Ignores double-clicks on the "Copy" checkbox column so toggling
/// it rapidly doesn't also launch the default player.
/// </summary>
private void TrackGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject source && FindAncestor<CheckBox>(source) != null) return;
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
}
/// <summary>
/// Walks up the visual tree from <paramref name="source"/> looking for an
/// ancestor (or the element itself) of type <typeparamref name="T"/>.
/// </summary>
private static T? FindAncestor<T>(DependencyObject source) where T : DependencyObject
{
var current = source;
while (current != null)
{
if (current is T typed) return typed;
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
}
return null;
}
private void OpenSelectedFileInDefaultPlayer() private void OpenSelectedFileInDefaultPlayer()
{ {
if (FileQueueList.SelectedItem is not QueuedFileItem item) return; if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
@@ -441,12 +609,16 @@ public partial class MainWindow : FluentWindow
TrackGrid.ItemsSource = file.Tracks; TrackGrid.ItemsSource = file.Tracks;
TrackGrid.Visibility = Visibility.Visible; TrackGrid.Visibility = Visibility.Visible;
NoSelectionHint.Visibility = Visibility.Collapsed; NoSelectionHint.Visibility = Visibility.Collapsed;
ExternalSubsList.ItemsSource = file.ExternalSubtitles;
ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
} }
else else
{ {
TrackGrid.ItemsSource = null; TrackGrid.ItemsSource = null;
TrackGrid.Visibility = Visibility.Collapsed; TrackGrid.Visibility = Visibility.Collapsed;
NoSelectionHint.Visibility = Visibility.Visible; NoSelectionHint.Visibility = Visibility.Visible;
ExternalSubsList.ItemsSource = null;
ExternalSubsPanel.Visibility = Visibility.Collapsed;
} }
} }
@@ -493,6 +665,7 @@ public partial class MainWindow : FluentWindow
_isProcessing = true; _isProcessing = true;
RunButton.IsEnabled = false; RunButton.IsEnabled = false;
StatusText.Text = string.Empty; StatusText.Text = string.Empty;
ProcessingOverlay.Visibility = Visibility.Visible;
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips) var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
{ {
@@ -501,6 +674,7 @@ public partial class MainWindow : FluentWindow
progressWin.Closed += (_, _) => progressWin.Closed += (_, _) =>
{ {
_isProcessing = false; _isProcessing = false;
ProcessingOverlay.Visibility = Visibility.Collapsed;
if (progressWin.ShouldClearQueue) if (progressWin.ShouldClearQueue)
{ {
foreach (var item in _fileQueue) 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.ComponentModel;
using System.IO; using System.IO;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Futonizer.Services;
namespace Futonizer.Models; namespace Futonizer.Models;
@@ -32,9 +33,29 @@ public class QueuedFileItem : INotifyPropertyChanged
public string FilePath { get; } public string FilePath { get; }
public string FileName => Path.GetFileName(FilePath); 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 string FileSizeDisplay { get; }
public ObservableCollection<TrackItem> Tracks { get; } = new(); 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 public string Status
{ {
get => _status; get => _status;
@@ -63,7 +84,12 @@ public class QueuedFileItem : INotifyPropertyChanged
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy); int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && 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.Correct
: FileLoadState.Incorrect; : FileLoadState.Incorrect;
} }
@@ -79,6 +105,7 @@ public class QueuedFileItem : INotifyPropertyChanged
FilePath = filePath; FilePath = filePath;
FileSizeDisplay = FormatFileSize(filePath); FileSizeDisplay = FormatFileSize(filePath);
Tracks.CollectionChanged += OnTracksChanged; Tracks.CollectionChanged += OnTracksChanged;
ExternalSubtitles.CollectionChanged += OnExternalSubtitlesChanged;
} }
private static string FormatFileSize(string filePath) 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) private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
if (e.NewItems != null) if (e.NewItems != null)
+317
View File
@@ -0,0 +1,317 @@
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);
// Folder suffix bracket release tag: "Show Name ... x264 [i_c]" → tag = "i_c"
// A bracketed tag at the very end of the folder name (as opposed to
// PrefixTagRegex, which matches one at the start).
private static readonly Regex FolderSuffixBracketTagRegex =
new(@"\[([^\[\]]+)\]$", 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". The word
// immediately preceding the dash must itself be recognisable quality/
// source/codec metadata (checked by the caller via IsMetadataToken), so
// an ordinary hyphenated title word (e.g. "Scary-oke") is never mistaken
// for a "-GroupName" suffix.
private static readonly Regex FileSuffixTagRegex =
new(@"(?:^|[\s.])([A-Za-z0-9]+)-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled);
// File suffix release tag directly after a bracketed metadata block:
// "...[HULU WEBDL-1080p][EAC3 5.1][h264]-NTb" → tag = "NTb"
// Common on naming-bot-style releases that group tech specs into their
// own brackets and tack the group on with a dash at the very end. The
// bracket immediately before the dash must itself be recognisable
// metadata (checked by the caller via IsMetadataBracket), mirroring
// FileSuffixTagRegex's guard for the bare-word case.
private static readonly Regex FileSuffixBracketDashTagRegex =
new(@"\[([^\[\]]+)\]-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled);
// Trailing parenthesised metadata block ending in the release group as its
// last word, e.g. "... (1080p AMZN WEB-DL x265 Celdra)" → group="Celdra".
// Common on Amazon/streaming rips that don't set the group off with a
// "-" or its own brackets.
private static readonly Regex TrailingParenGroupRegex =
new(@"\(([^()]*)\)$", 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(-HD)?|TrueHD|Atmos|E?AC3|DDP?|Opus|MP3)(\d(\.\d)?)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs (+ optional channel suffix, e.g. DDP5.1)
new(@"^\d\.\d$", RegexOptions.Compiled), // bare channel count, e.g. 5.1
new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
];
// Whole-phrase fansub/release descriptor patterns, checked against an
// entire bracket's contents rather than word-by-word. Catches common
// multi-word descriptors (e.g. "Multiple Subtitle", "Dual Audio") whose
// individual words wouldn't otherwise match any MetadataPatterns entry,
// so they'd be mistaken for a release group name.
private static readonly Regex[] DescriptorPhrasePatterns =
[
new(@"^Multi(ple)?[\s-]?Sub(s|title|titles)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"^(Dual|Multi)[\s-]?Audio$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"^(Hard|Soft)[\s-]?sub(s)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"^(Uncensored|Censored|Batch|Complete)$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
];
// -------------------------------------------------------------------------
/// <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, a group tacked on after a bracketed metadata
/// block (e.g. <c>[h264]-NTb</c>), or a release group tucked in as
/// the last word of a trailing <c>(...)</c> metadata block (e.g.
/// Amazon rips like <c>(1080p AMZN WEB-DL x265 Celdra)</c>), 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) ?? "");
// Capture the file's own pre-existing bracket prefix (if any) before any
// transformation below, so we never stack a second/different tag on top
// of a file that already carries its own release-group prefix.
var originalPrefixMatch = PrefixTagRegex.Match(stemNoExt);
string? originalPrefixTag = originalPrefixMatch.Success ? originalPrefixMatch.Groups[1].Value : null;
// 1. Extract in-filename suffix tag and remove it from the stem.
// e.g. "...x264-DemiHuman" → tag="DemiHuman", stem="...x264"
// Only fires when the word right before the dash is itself a
// recognised metadata token (codec/source/etc.), so ordinary
// hyphenated title words are left alone.
string? fileTag = null;
var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt);
var bracketDashMatch = FileSuffixBracketDashTagRegex.Match(stemNoExt);
if (fileSuffixMatch.Success
&& IsMetadataToken(fileSuffixMatch.Groups[1].Value)
&& !IsMetadataToken(fileSuffixMatch.Groups[2].Value))
{
fileTag = fileSuffixMatch.Groups[2].Value;
stemNoExt = stemNoExt[..(fileSuffixMatch.Groups[2].Index - 1)];
fileName = stemNoExt + ext;
}
else if (bracketDashMatch.Success
&& IsMetadataBracket(bracketDashMatch.Groups[1].Value)
&& !IsMetadataToken(bracketDashMatch.Groups[2].Value))
{
// 1a. Extract a release group tacked on after a bracketed metadata
// block, e.g. "...[h264]-NTb" → tag="NTb", stem="...[h264]".
fileTag = bracketDashMatch.Groups[2].Value;
stemNoExt = stemNoExt[..(bracketDashMatch.Groups[2].Index - 1)];
fileName = stemNoExt + ext;
}
else
{
// 1b. Extract a release group tucked in as the last word of a
// trailing "(...)" metadata block, e.g.
// "... (1080p AMZN WEB-DL x265 Celdra)" → tag="Celdra",
// stem="... (1080p AMZN WEB-DL x265)".
var parenMatch = TrailingParenGroupRegex.Match(stemNoExt);
if (parenMatch.Success)
{
string parenContent = parenMatch.Groups[1].Value;
var words = parenContent.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// Only treat the last word as a release group if the rest of the
// block is recognisable quality/source/codec metadata — this
// avoids misfiring on unrelated parentheticals like "(2020)" or
// "(Dual Audio)" that don't carry a group name.
if (words.Length >= 2 && !IsMetadataToken(words[^1]) && words[..^1].Any(IsMetadataToken))
{
fileTag = words[^1];
string remaining = string.Join(" ", words[..^1]);
string beforeParen = stemNoExt[..parenMatch.Index].TrimEnd();
stemNoExt = string.IsNullOrEmpty(remaining) ? beforeParen : $"{beforeParen} ({remaining})";
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. Skip when the file
// already carries its own (different) bracket prefix — never stack
// a second tag on top of an existing one.
if (!string.IsNullOrEmpty(tag))
{
bool differsFromOriginalTag = originalPrefixTag != null
&& !string.Equals(originalPrefixTag, tag, StringComparison.OrdinalIgnoreCase);
if (!differsFromOriginalTag)
{
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,
/// checking (in priority order): a dash suffix (<c>-TAG</c>), a bracketed
/// suffix (<c>[tag]</c> at the end), then a bracketed prefix (<c>[tag]</c>
/// at the start). Returns <c>null</c> when none are present.
/// </summary>
public static string? ExtractReleaseTag(string folderName)
{
var suffixMatch = FolderSuffixTagRegex.Match(folderName);
if (suffixMatch.Success && !IsMetadataToken(suffixMatch.Groups[1].Value))
return suffixMatch.Groups[1].Value;
var bracketSuffixMatch = FolderSuffixBracketTagRegex.Match(folderName);
if (bracketSuffixMatch.Success && !IsMetadataBracket(bracketSuffixMatch.Groups[1].Value))
return bracketSuffixMatch.Groups[1].Value;
var prefixMatch = PrefixTagRegex.Match(folderName);
if (prefixMatch.Success && !IsMetadataBracket(prefixMatch.Groups[1].Value))
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 = FolderSuffixBracketTagRegex.Replace(name, "").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;
}
/// <summary>
/// True when every word inside a bracketed block is recognisable quality/
/// source/codec metadata (e.g. "[HEVC]", "[1080p-FLAC]"), meaning it's
/// just another metadata tag rather than an actual release group name
/// (e.g. "[i_c]").
/// </summary>
private static bool IsMetadataBracket(string content)
{
string trimmed = content.Trim();
if (DescriptorPhrasePatterns.Any(p => p.IsMatch(trimmed))) return true;
var parts = trimmed.Split(new[] { ' ', '-' }, StringSplitOptions.RemoveEmptyEntries);
return parts.Length > 0 && parts.All(IsMetadataToken);
}
}
+51 -8
View File
@@ -93,9 +93,12 @@ public class MkvService
/// <summary> /// <summary>
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video /// Builds the list of <see cref="TrackItem"/> objects for a file. Video
/// tracks are always pre-checked. Exactly one audio track is pre-checked: /// tracks are always pre-checked. Exactly one audio track is always
/// among Japanese/Chinese/undefined-language tracks, a stereo (2-channel) /// pre-checked: among Japanese/Chinese/undefined-language tracks, a
/// track is preferred, otherwise the first matching track in file order. /// stereo (2-channel) track is preferred, otherwise the first matching
/// track in file order. If no such track exists (e.g. only English
/// audio), the very first audio track in the file is pre-checked instead,
/// so a file is never left with no audio track selected.
/// Subtitle tracks are never pre-checked — the user picks one via the /// Subtitle tracks are never pre-checked — the user picks one via the
/// track table, and the choice propagates to other queued files. /// track table, and the choice propagates to other queued files.
/// </summary> /// </summary>
@@ -108,7 +111,8 @@ public class MkvService
.ToList(); .ToList();
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2) MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
?? eligibleAudio.FirstOrDefault(); ?? eligibleAudio.FirstOrDefault()
?? audioTracks.FirstOrDefault();
var items = info.Tracks.Select(t => var items = info.Tracks.Select(t =>
{ {
@@ -258,12 +262,13 @@ public class MkvService
IReadOnlyList<int> videoKeepIds, IReadOnlyList<int> videoKeepIds,
IReadOnlyList<int> audioKeepIds, IReadOnlyList<int> audioKeepIds,
IReadOnlyList<int> subtitleKeepIds, IReadOnlyList<int> subtitleKeepIds,
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles = null,
IProgress<ProcessingProgress>? progress = null, IProgress<ProcessingProgress>? progress = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return StripTracksInternalAsync( return StripTracksInternalAsync(
filePath, Path.GetFileName(filePath), outputFilePath, filePath, Path.GetFileName(filePath), outputFilePath,
videoKeepIds, audioKeepIds, subtitleKeepIds, videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
progress, ct); progress, ct);
} }
@@ -281,6 +286,7 @@ public class MkvService
null, // null = keep all video null, // null = keep all video
scan.AudioKeepIds, scan.AudioKeepIds,
scan.SubtitleKeepIds, scan.SubtitleKeepIds,
null,
progress, ct); progress, ct);
} }
@@ -386,6 +392,7 @@ public class MkvService
IReadOnlyList<int>? videoKeepIds, IReadOnlyList<int>? videoKeepIds,
IReadOnlyList<int>? audioKeepIds, IReadOnlyList<int>? audioKeepIds,
IReadOnlyList<int>? subtitleKeepIds, IReadOnlyList<int>? subtitleKeepIds,
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles,
IProgress<ProcessingProgress>? progress, IProgress<ProcessingProgress>? progress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -422,16 +429,52 @@ public class MkvService
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds); AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds); AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
// The one subtitle track the user chose to keep is always flagged // Only one subtitle track in the whole output should ever be
// as the default subtitle track in the output. // 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) if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
{ {
psi.ArgumentList.Add("--default-track-flag"); 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); 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 }; using var process = new Process { StartInfo = psi };
process.Start(); 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); 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) => job.PropertyChanged += (_, args) =>
{ {
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond)) if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
@@ -157,9 +158,11 @@ public partial class ProgressWindow : FluentWindow
try try
{ {
string sourceDir = Path.GetDirectoryName(file.FilePath) ?? "";
string outputFilePath = overwritingInPlace string outputFilePath = overwritingInPlace
? file.FilePath ? Path.Combine(sourceDir, computedFileName)
: Path.Combine(_outputFolder, file.FileName); : Path.Combine(_outputFolder, computedFileName);
var videoIds = file.Tracks var videoIds = file.Tracks
.Where(t => t.Type == "video" && t.Copy) .Where(t => t.Type == "video" && t.Copy)
@@ -171,7 +174,7 @@ public partial class ProgressWindow : FluentWindow
.Where(t => t.Type == "subtitles" && t.Copy) .Where(t => t.Type == "subtitles" && t.Copy)
.Select(t => t.Id).ToList(); .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 => var progress = new Progress<ProcessingProgress>(p =>
{ {
@@ -184,10 +187,14 @@ public partial class ProgressWindow : FluentWindow
: await service.StripTracksAsync( : await service.StripTracksAsync(
file.FilePath, outputFilePath, file.FilePath, outputFilePath,
videoIds, audioIds, subIds, videoIds, audioIds, subIds,
file.ExternalSubtitles.ToList(),
progress, ct); progress, ct);
string tag = result.Success ? "[DONE]" : "[FAIL]"; 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 HistoryService.Add(new HistoryEntry
{ {