4 Commits
Author SHA1 Message Date
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
10 changed files with 607 additions and 37 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName> <AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace> <RootNamespace>Futonizer</RootNamespace>
<Version>1.0.4</Version> <Version>1.0.8</Version>
<FileVersion>1.0.4.0</FileVersion> <FileVersion>1.0.8.0</FileVersion>
<ApplicationIcon>Assets\app.ico</ApplicationIcon> <ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
+63 -3
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"/>
@@ -158,6 +158,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>
@@ -179,12 +180,21 @@
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 +219,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"
@@ -328,6 +387,7 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</Grid>
</Grid> </Grid>
</Border> </Border>
+146 -9
View File
@@ -20,6 +20,7 @@ public partial class MainWindow : FluentWindow
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;
@@ -196,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
{ {
@@ -208,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;
@@ -215,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 ─────────────────────────────────────────────────────────
@@ -457,12 +590,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;
} }
} }
+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));
}
+27 -1
View File
@@ -43,6 +43,19 @@ public class QueuedFileItem : INotifyPropertyChanged
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;
@@ -71,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;
} }
@@ -87,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)
@@ -112,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)
+88 -12
View File
@@ -20,14 +20,26 @@ public static class FileNameHelper
private static readonly Regex FileSuffixTagRegex = private static readonly Regex FileSuffixTagRegex =
new(@"-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled); 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" // Prefix release tag: "[KAA] Show Name" → tag = "KAA"
private static readonly Regex PrefixTagRegex = private static readonly Regex PrefixTagRegex =
new(@"^\[(.+?)\]", RegexOptions.Compiled); new(@"^\[(.+?)\]", RegexOptions.Compiled);
// Episode-only filename: starts with S01E01, S1E1, etc. (no show name present) // Episode-only filename (no tag prefix): starts with S01E01, S1E1, etc.
private static readonly Regex EpisodeOnlyRegex = private static readonly Regex EpisodeOnlyRegex =
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled); 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 // Individual metadata-token patterns used to find where the show name ends
// inside a folder name. // inside a folder name.
private static readonly Regex[] MetadataPatterns = private static readonly Regex[] MetadataPatterns =
@@ -40,7 +52,8 @@ public static class FileNameHelper
new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources 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(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs 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(@"^(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 new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
]; ];
@@ -51,8 +64,10 @@ public static class FileNameHelper
/// (in order): /// (in order):
/// <list type="number"> /// <list type="number">
/// <item>Filename suffix tag extraction — a trailing <c>-GroupName</c> on /// <item>Filename suffix tag extraction — a trailing <c>-GroupName</c> on
/// the filename stem is stripped and promoted to a bracket prefix. /// the filename stem, or a release group tucked in as the last word
/// Takes priority over any folder-derived tag.</item> /// 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 /// <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 /// an episode code (e.g. S01E01) the show name is derived from the
/// parent folder and prepended.</item> /// parent folder and prepended.</item>
@@ -80,18 +95,66 @@ public static class FileNameHelper
stemNoExt = stemNoExt[..fileSuffixMatch.Index]; stemNoExt = stemNoExt[..fileSuffixMatch.Index];
fileName = stemNoExt + ext; 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. // 2. Determine the release tag: filename suffix > folder tag.
string? tag = fileTag; string? tag = fileTag;
if (string.IsNullOrEmpty(tag) && !string.IsNullOrEmpty(folderName)) if (string.IsNullOrEmpty(tag) && !string.IsNullOrEmpty(folderName))
tag = ExtractReleaseTag(folderName); tag = ExtractReleaseTag(folderName);
// 3. Inject show name when only an episode code is present. // 3. Check for episode-only patterns and inject show name if needed.
if (EpisodeOnlyRegex.IsMatch(stemNoExt) && !string.IsNullOrEmpty(folderName)) if (!string.IsNullOrEmpty(folderName))
{ {
string showName = ExtractShowName(folderName); // Pattern A: untagged stem starts with S01E01 (e.g. "S01E01-Deep Blue.mkv")
if (!string.IsNullOrEmpty(showName)) if (EpisodeOnlyRegex.IsMatch(stemNoExt))
fileName = showName + " " + fileName; {
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. // 4. Prepend release tag if not already present.
@@ -123,8 +186,13 @@ public static class FileNameHelper
/// <summary> /// <summary>
/// Derives the show name from a folder name by stripping any release tag /// Derives the show name from a folder name by stripping any release tag
/// (prefix or suffix) and then taking all words that precede the first /// (prefix or suffix) and then:
/// recognised metadata token (season, quality, source, codec, etc.). /// <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> /// </summary>
public static string ExtractShowName(string folderName) public static string ExtractShowName(string folderName)
{ {
@@ -132,7 +200,15 @@ public static class FileNameHelper
string name = FolderSuffixTagRegex.Replace(folderName, "").Trim(); string name = FolderSuffixTagRegex.Replace(folderName, "").Trim();
name = PrefixTagRegex.Replace(name, "").Trim(); name = PrefixTagRegex.Replace(name, "").Trim();
// Collect words up to the first metadata token. // 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 words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var showWords = new List<string>(); var showWords = new List<string>();
foreach (var word in words) foreach (var word in words)
+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}";
}
}
+6 -2
View File
@@ -174,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 =>
{ {
@@ -187,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} {Path.GetFileName(result.OutputFilePath)} — {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
{ {