13 Commits
Author SHA1 Message Date
l4kr 72ef75505b Inject show name for bare-number episode filenames
Previously, only S01E01-style episode codes triggered show-name
injection from the folder name. Plain numbered filenames (e.g.
"11 - Title.mkv") with a release tag sourced only from the folder
(e.g. "[DmonHiro] Digimon Tamers (BD, 1080p)") were left untouched
apart from the tag prefix.

Also fixes ExtractShowName to strip trailing parenthetical metadata
blocks like "(BD, 1080p)", which previously leaked into the derived
show name.
2026-07-24 14:30:34 +02:00
l4kr 05fbe34e20 Match subtitles to episodes by plain episode number, not just S04E06
Adds a season-agnostic fallback to subtitle-episode matching so dropped
subtitle files no longer need a full SxxEyy code to be attached to the
right video. Recognises bare numbers (06, 6, 16), explicit markers
without a season (E06, EP06, Episode 06), and numbers cleanly delimited
by separators, including the common anime release style 'Show - 06 -
Title'. Guards against false positives from resolutions (1080p), years,
and decimal-looking audio tags (DD5.1) by requiring clean separator
boundaries and capping bare numbers at a few digits.

Full season+episode codes still take priority and match exactly as
before; the new bare-number matching only kicks in when one side of the
match lacks season info.

Note: dotnet is not reachable in this environment, so the required
Release build validation could not be run before this commit.
2026-07-24 12:10:06 +00:00
l4kr e24a401ffb Add AGENTS.md with release workflow instructions
Codifies the publish process this project follows so any AI agent
picks it up automatically: bump patch version every change, validate
before publishing, commit/tag/push to the Gitea remote, build the
self-contained win-x64 exe, and keep only the 3 most recent release
zips locally.
2026-07-14 11:17:23 +02:00
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
12 changed files with 1008 additions and 74 deletions
+1
View File
@@ -1,5 +1,6 @@
bin/
obj/
publish/
*.user
history.json
settings.json
+99
View File
@@ -0,0 +1,99 @@
# Futonizer — Agent Instructions
These instructions apply to every change made in this project by an AI
coding agent. Follow them automatically, without being asked, whenever you
finish a piece of work here.
## 1. Always bump the version after making a change
Every time you make a code change (bug fix, feature, tweak — anything that
isn't a pure no-op), bump the patch version by exactly `0.0.1` in
`Futonizer.csproj`:
```xml
<Version>X.Y.Z</Version>
<FileVersion>X.Y.Z.0</FileVersion>
```
Both lines must be bumped together and stay in sync (`FileVersion` is just
`Version` + `.0`). Check the current value with:
```
grep -n "<Version>" Futonizer.csproj
```
Do this even for small fixes — one bump per unit of work you deliver, not
per file changed.
## 2. Build and validate before publishing
1. `cd Futonizer && "/mnt/c/Program Files/dotnet/dotnet.exe" build Futonizer.csproj -c Release`
— must show `0 Warning(s)` / `0 Error(s)`.
2. Run the `diagnostics` tool on any file you touched — must come back clean.
3. If you changed `Services/FileNameHelper.cs` (or similar pure-logic
files), validate with a disposable scratch console project instead of
trusting the change blindly:
- Create `Futonizer/_scratch_test/scratch.csproj` (minimal console app,
`net10.0`, with `<Compile Include="..\Services\FileNameHelper.cs" />`).
- Write test cases in `Program.cs` covering the new behavior **plus**
every previously-established regression case (accumulate these across
sessions — don't just test the newest scenario).
- Run via `"/mnt/c/Program Files/dotnet/dotnet.exe" run --project _scratch_test/scratch.csproj`.
- Confirm all cases pass, then **delete `_scratch_test`** before
committing — it must never be committed.
## 3. Commit, tag, and push
```
git add -A
GIT_EDITOR=true git commit -m "<short imperative summary>
<optional body explaining the bug/feature, root cause, and fix>"
GIT_EDITOR=true git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin main --tags
```
Remote is a Gitea instance (`sshtea.genoslab.net`), not GitHub — there is
no `gh` CLI and no automated release upload. Publishing is git-only
(commit + annotated tag + push).
## 4. Publish a self-contained build
```
"/mnt/c/Program Files/dotnet/dotnet.exe" publish Futonizer.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish
mkdir -p "publish/Futonizer-vX.Y.Z" && cp publish/Futonizer.exe "publish/Futonizer-vX.Y.Z/Futonizer.exe"
rm -f publish/Futonizer.exe publish/Futonizer.pdb
powershell.exe -NoProfile -Command "Compress-Archive -Path 'publish\Futonizer-vX.Y.Z\Futonizer.exe' -DestinationPath 'Futonizer-vX.Y.Z.zip' -CompressionLevel Optimal"
rm -rf publish/Futonizer-vX.Y.Z bin/Release/net10.0-windows/win-x64
```
Notes:
- `dotnet` is only reachable via the full path
`"/mnt/c/Program Files/dotnet/dotnet.exe"` (not on `$PATH` as `dotnet`
in this shell).
- There's no `zip`/`7z` in this shell — use PowerShell's
`Compress-Archive` via `powershell.exe -NoProfile -Command "..."`.
- Verify the resulting zip contains only `Futonizer.exe` (~69-72MB
compressed) with `unzip -l Futonizer-vX.Y.Z.zip`.
## 5. Only keep the last 3 release zips
`*.zip` files live in the project root and are gitignored (kept locally
only, per user preference — not committed). After publishing a new
version, **delete any zip beyond the 3 most recent versions**:
```
ls -1v Futonizer-v*.zip
```
Sort by version (not by date/name alone — use natural/version sort as
shown above), keep the newest 3, delete the rest, e.g.:
```
rm -f Futonizer-v<oldest>.zip
```
## 6. Report back
Summarize what changed, what validation was run (and its result), and
confirm the new version number, commit hash, tag, and zip filename.
+2 -2
View File
@@ -9,8 +9,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace>
<Version>1.0.3</Version>
<FileVersion>1.0.3.0</FileVersion>
<Version>1.0.14</Version>
<FileVersion>1.0.14.0</FileVersion>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup>
+65 -4
View File
@@ -137,7 +137,7 @@
<Grid Grid.Row="1">
<TextBlock x:Name="DropHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Drop .mkv files&#10;onto this window"
Text="Drop .mkv files (and subtitles)&#10;onto this window"
Opacity="0.4" FontSize="14" TextAlignment="Center"
IsHitTestVisible="False"/>
@@ -149,6 +149,7 @@
VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
PreviewKeyDown="FileQueueList_PreviewKeyDown"
MouseDoubleClick="FileQueueList_MouseDoubleClick"
ItemContainerStyle="{StaticResource QueueListBoxItemStyle}"
SelectionChanged="FileQueueList_SelectionChanged">
<ListBox.ItemTemplate>
@@ -158,6 +159,7 @@
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="18"/>
</Grid.ColumnDefinitions>
@@ -179,12 +181,21 @@
FontSize="12" VerticalAlignment="Center" Margin="6,0"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="2" Text="{Binding FileSizeDisplay}"
<Border Grid.Column="2" CornerRadius="8" Padding="5,1" Margin="2,0"
VerticalAlignment="Center"
Background="{DynamicResource AccentFillColorSecondaryBrush}"
Visibility="{Binding HasExternalSubtitles, Converter={StaticResource BoolToVis}}"
ToolTip="{Binding ExternalSubtitlesTooltip}">
<TextBlock Text="{Binding ExternalSubtitles.Count, StringFormat={}+{0} sub}"
FontSize="9" FontWeight="SemiBold"/>
</Border>
<TextBlock Grid.Column="3" Text="{Binding FileSizeDisplay}"
FontSize="11" VerticalAlignment="Center" Margin="4,0"
Opacity="0.6"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
<Button Grid.Column="3" Content="&#xD7;"
<Button Grid.Column="4" Content="&#xD7;"
Tag="{Binding}"
Click="RemoveFile_Click"
Background="Transparent" BorderThickness="0"
@@ -209,6 +220,55 @@
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- External subtitles matched to the selected file -->
<Border x:Name="ExternalSubsPanel" Grid.Row="0" Margin="10,8,10,0"
Padding="8,6" CornerRadius="6" Visibility="Collapsed"
Background="{DynamicResource ControlFillColorSecondaryBrush}">
<StackPanel>
<TextBlock Text="External subtitles to mux in" FontSize="11"
FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
<ItemsControl x:Name="ExternalSubsList">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
BorderThickness="1" CornerRadius="4"
Margin="0,0,6,6" Padding="6,3">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding LanguageDisplay}" FontSize="11"
FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding FileName}" FontSize="11" Opacity="0.6"
Margin="6,0,0,0" VerticalAlignment="Center"
MaxWidth="220" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="DEFAULT" FontSize="9" FontWeight="Bold"
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
Margin="6,0,0,0" VerticalAlignment="Center"
Visibility="{Binding IsDefault, Converter={StaticResource BoolToVis}}"/>
<Button Content="&#xD7;" Tag="{Binding}"
Click="RemoveExternalSubtitle_Click"
Background="Transparent" BorderThickness="0"
Padding="6,0,0,0" Cursor="Hand" FontSize="13"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Grid Grid.Row="1">
<TextBlock x:Name="NoSelectionHint"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="Select a file to view its tracks"
@@ -230,7 +290,7 @@
GridLinesVisibility="None"
VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
PreviewKeyDown="TrackGrid_PreviewKeyDown"
MouseDoubleClick="TrackGrid_MouseDoubleClick"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<DataGrid.RowStyle>
@@ -329,6 +389,7 @@
</DataGrid.Columns>
</DataGrid>
</Grid>
</Grid>
</Border>
<!-- Processing overlay: dims the entire content area while stripping is running -->
+194 -18
View File
@@ -20,6 +20,7 @@ public partial class MainWindow : FluentWindow
private AppSettings _settings = new();
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
private readonly List<string> _pendingSubtitles = new();
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
private bool _isProcessing;
private bool _propagatingSelection;
@@ -196,10 +197,9 @@ public partial class MainWindow : FluentWindow
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
bool hasMkv = paths.Any(p =>
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
&& File.Exists(p));
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
bool hasAcceptedFile = paths.Any(p =>
File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
}
else
{
@@ -208,6 +208,9 @@ public partial class MainWindow : FluentWindow
e.Handled = true;
}
private static bool IsMkvFile(string path)
=> string.Equals(Path.GetExtension(path), ".mkv", StringComparison.OrdinalIgnoreCase);
private void Window_Drop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
@@ -215,28 +218,178 @@ public partial class MainWindow : FluentWindow
Activate();
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
var newFiles = paths
.Where(p => string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
var newVideoPaths = paths
.Where(p => IsMkvFile(p)
&& File.Exists(p)
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
.ToList();
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);
item.PropertyChanged += QueuedFileItem_PropertyChanged;
_fileQueue.Add(item);
newItems.Add(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();
if (newFiles.Count > 0)
if (newItems.Count > 0)
{
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;
}
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 (S04E06, E06, or plain episode number like 06/6/16)."
: $"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. Prefers a full season+episode code (e.g.
/// S04E06) when the subtitle's filename has one; otherwise falls back to
/// matching by bare episode number alone (e.g. "06", "6", "16", "E06"),
/// season-agnostic. Falls back further to the single queued file when
/// there's exactly one and no episode info at all 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? subCode = SubtitleMatcher.ExtractEpisodeCode(subFileName);
int? subEpisodeNumber = subCode == null ? SubtitleMatcher.ExtractEpisodeNumber(subFileName) : null;
List<QueuedFileItem> targets;
if (subCode != null)
{
targets = _fileQueue
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), subCode, StringComparison.OrdinalIgnoreCase))
.ToList();
}
else if (subEpisodeNumber != null)
{
targets = _fileQueue
.Where(q => SubtitleMatcher.ExtractEpisodeNumber(q.FileName) == subEpisodeNumber)
.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 matching this video's
/// episode — by full season+episode code when both have one, or by bare
/// episode number otherwise — and attaches + removes any matches from
/// the pending list.
/// </summary>
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
{
if (_pendingSubtitles.Count == 0) return;
string? videoCode = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
int? videoEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(item.FileName);
if (videoCode == null && videoEpisodeNumber == null) return;
var matches = _pendingSubtitles.Where(p =>
{
string subName = Path.GetFileName(p);
string? subCode = SubtitleMatcher.ExtractEpisodeCode(subName);
if (subCode != null)
return videoCode != null && string.Equals(subCode, videoCode, StringComparison.OrdinalIgnoreCase);
int? subEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(subName);
return subEpisodeNumber != null && subEpisodeNumber == videoEpisodeNumber;
}).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 ─────────────────────────────────────────────────────────
@@ -386,13 +539,6 @@ public partial class MainWindow : FluentWindow
/// </summary>
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;
var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList();
@@ -412,13 +558,39 @@ public partial class MainWindow : FluentWindow
/// Windows has associated with .mkv files (typically the default video
/// player), so the user can quickly preview a file without leaving the app.
/// </summary>
private void TrackGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
private void FileQueueList_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Space) return;
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()
{
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
@@ -457,12 +629,16 @@ public partial class MainWindow : FluentWindow
TrackGrid.ItemsSource = file.Tracks;
TrackGrid.Visibility = Visibility.Visible;
NoSelectionHint.Visibility = Visibility.Collapsed;
ExternalSubsList.ItemsSource = file.ExternalSubtitles;
ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
}
else
{
TrackGrid.ItemsSource = null;
TrackGrid.Visibility = Visibility.Collapsed;
NoSelectionHint.Visibility = Visibility.Visible;
ExternalSubsList.ItemsSource = null;
ExternalSubsPanel.Visibility = Visibility.Collapsed;
}
}
+59
View File
@@ -0,0 +1,59 @@
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 (e.g. "S04E06", or
/// just a bare episode number like "06"), 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));
}
+28 -1
View File
@@ -43,6 +43,20 @@ public class QueuedFileItem : INotifyPropertyChanged
public string FileSizeDisplay { get; }
public ObservableCollection<TrackItem> Tracks { get; } = new();
/// <summary>
/// External subtitle files (dropped separately and matched to this file by
/// episode — e.g. "S04E06", or just a bare episode number like "06") that
/// will be muxed in as additional subtitle tracks when this file is
/// processed.
/// </summary>
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
public bool HasExternalSubtitles => ExternalSubtitles.Count > 0;
public string ExternalSubtitlesTooltip => ExternalSubtitles.Count == 0
? string.Empty
: "External subtitles to mux in:\n" + string.Join("\n", ExternalSubtitles.Select(s => $"- {s.FileName} ({s.LanguageDisplay})"));
public string Status
{
get => _status;
@@ -71,7 +85,12 @@ public class QueuedFileItem : INotifyPropertyChanged
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy);
return audioSelected == 1 && subtitleSelected == 1
// A subtitle requirement is met either by picking exactly one
// internal track, or by having at least one matched external
// subtitle file (which can also be combined with one internal track).
bool subtitleOk = subtitleSelected == 1 || (subtitleSelected == 0 && ExternalSubtitles.Count > 0);
return audioSelected == 1 && subtitleOk
? FileLoadState.Correct
: FileLoadState.Incorrect;
}
@@ -87,6 +106,7 @@ public class QueuedFileItem : INotifyPropertyChanged
FilePath = filePath;
FileSizeDisplay = FormatFileSize(filePath);
Tracks.CollectionChanged += OnTracksChanged;
ExternalSubtitles.CollectionChanged += OnExternalSubtitlesChanged;
}
private static string FormatFileSize(string filePath)
@@ -112,6 +132,13 @@ public class QueuedFileItem : INotifyPropertyChanged
}
}
private void OnExternalSubtitlesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(HasExternalSubtitles));
OnPropertyChanged(nameof(ExternalSubtitlesTooltip));
NotifyLoadState();
}
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
+249 -31
View File
@@ -9,19 +9,65 @@ namespace Futonizer.Services;
/// </summary>
public static class FileNameHelper
{
// Suffix release tag: "Show Name-TTGA" → tag = "TTGA"
// Must be 2-8 uppercase letters/digits at the very end, preceded by a dash.
private static readonly Regex SuffixTagRegex =
// 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: 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 =
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);
// Bare leading episode number with no tag of its own on the file (the
// release tag, if any, comes only from the folder), e.g. "11 - Title.mkv",
// "06_Title.mkv", "06.Title.mkv". Requires a separator right after the
// digits so resolution/quality tokens glued to more characters (e.g.
// "1080p") aren't mistaken for an episode number lead-in.
private static readonly Regex BareLeadingEpisodeNumberRegex =
new(@"^\d{1,3}(?=[\s._-])", RegexOptions.Compiled);
// Individual metadata-token patterns used to find where the show name ends
// inside a folder name.
private static readonly Regex[] MetadataPatterns =
@@ -34,84 +80,241 @@ 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(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs
new(@"^(FLAC|AAC|DTS|TrueHD|Atmos|AC3|EAC3|Opus|MP3)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs
new(@"^(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>Show-name injection — if the 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 derived from
/// the parent folder name, either from a suffix pattern (<c>-TAG</c>)
/// or a prefix pattern (<c>[TAG]</c>).</item>
/// <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), or with a bare episode number
/// (e.g. "11 - Title") once a release tag has been resolved, 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 the parent folder provides
/// no actionable information.
/// 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);
if (string.IsNullOrEmpty(folder)) return fileName;
string folderName = string.IsNullOrEmpty(folder) ? "" : (Path.GetFileName(folder) ?? "");
string folderName = Path.GetFileName(folder);
if (string.IsNullOrEmpty(folderName)) return fileName;
// 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. Inject show name when only an episode code is present.
string fileNameNoExt = Path.GetFileNameWithoutExtension(fileName);
if (EpisodeOnlyRegex.IsMatch(fileNameNoExt))
// 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..];
}
}
}
// Pattern C: file has no tag of its own — the tag came only from
// the folder — but the filename still starts with a bare
// episode number, e.g. "11 - Title.mkv" in a
// "[DmonHiro] Show Name (BD, 1080p)" folder. Inject the show
// name here; the tag is prepended separately in step 4.
else if (BareLeadingEpisodeNumberRegex.IsMatch(stemNoExt))
{
string showName = ExtractShowName(folderName);
if (!string.IsNullOrEmpty(showName))
fileName = showName + " " + fileName;
}
}
}
// 2. Prepend release tag if not already present.
string? tag = ExtractReleaseTag(folderName);
// 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.
/// Suffix pattern (<c>-TAG</c>) takes priority over prefix pattern
/// (<c>[TAG]</c>). Returns <c>null</c> when neither is present.
/// 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 = SuffixTagRegex.Match(folderName);
if (suffixMatch.Success) return suffixMatch.Groups[1].Value;
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) return prefixMatch.Groups[1].Value;
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 taking all words that precede the first
/// recognised metadata token (season, quality, source, codec, etc.).
/// (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 = SuffixTagRegex.Replace(folderName, "").Trim();
string name = FolderSuffixTagRegex.Replace(folderName, "").Trim();
name = FolderSuffixBracketTagRegex.Replace(name, "").Trim();
name = PrefixTagRegex.Replace(name, "").Trim();
// Collect words up to the first metadata token.
// Strip a trailing parenthesised metadata block, e.g. "(BD, 1080p)",
// when every comma/space-separated word inside it is recognisable
// quality/source metadata — so a real release group or descriptive
// parenthetical left at the end of the name isn't mistakenly dropped.
var trailingParenMatch = TrailingParenGroupRegex.Match(name);
if (trailingParenMatch.Success)
{
var parenWords = trailingParenMatch.Groups[1].Value
.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries);
if (parenWords.Length > 0 && parenWords.All(IsMetadataToken))
name = name[..trailingParenMatch.Index].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)
@@ -129,4 +332,19 @@ public static class FileNameHelper
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>
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
/// tracks are always pre-checked. Exactly one audio track is pre-checked:
/// among Japanese/Chinese/undefined-language tracks, a stereo (2-channel)
/// track is preferred, otherwise the first matching track in file order.
/// tracks are always pre-checked. Exactly one audio track is always
/// pre-checked: among Japanese/Chinese/undefined-language tracks, a
/// 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
/// track table, and the choice propagates to other queued files.
/// </summary>
@@ -108,7 +111,8 @@ public class MkvService
.ToList();
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
?? eligibleAudio.FirstOrDefault();
?? eligibleAudio.FirstOrDefault()
?? audioTracks.FirstOrDefault();
var items = info.Tracks.Select(t =>
{
@@ -258,12 +262,13 @@ public class MkvService
IReadOnlyList<int> videoKeepIds,
IReadOnlyList<int> audioKeepIds,
IReadOnlyList<int> subtitleKeepIds,
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles = null,
IProgress<ProcessingProgress>? progress = null,
CancellationToken ct = default)
{
return StripTracksInternalAsync(
filePath, Path.GetFileName(filePath), outputFilePath,
videoKeepIds, audioKeepIds, subtitleKeepIds,
videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
progress, ct);
}
@@ -281,6 +286,7 @@ public class MkvService
null, // null = keep all video
scan.AudioKeepIds,
scan.SubtitleKeepIds,
null,
progress, ct);
}
@@ -386,6 +392,7 @@ public class MkvService
IReadOnlyList<int>? videoKeepIds,
IReadOnlyList<int>? audioKeepIds,
IReadOnlyList<int>? subtitleKeepIds,
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles,
IProgress<ProcessingProgress>? progress,
CancellationToken ct)
{
@@ -422,16 +429,52 @@ public class MkvService
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
// The one subtitle track the user chose to keep is always flagged
// as the default subtitle track in the output.
// Only one subtitle track in the whole output should ever be
// flagged default. If any external subtitle is marked default,
// it takes priority over the kept internal subtitle track.
bool hasExternalDefault = externalSubtitles != null && externalSubtitles.Any(s => s.IsDefault);
if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
{
psi.ArgumentList.Add("--default-track-flag");
psi.ArgumentList.Add($"{subtitleKeepIds[0]}:yes");
psi.ArgumentList.Add(hasExternalDefault ? $"{subtitleKeepIds[0]}:no" : $"{subtitleKeepIds[0]}:yes");
}
psi.ArgumentList.Add(filePath);
// Append each matched external subtitle file as its own input,
// with per-file options (language/name/flags) scoped to it.
if (externalSubtitles != null)
{
foreach (var sub in externalSubtitles)
{
if (!string.IsNullOrEmpty(sub.Language) && !string.Equals(sub.Language, "und", StringComparison.OrdinalIgnoreCase))
{
psi.ArgumentList.Add("--language");
psi.ArgumentList.Add($"0:{sub.Language}");
}
if (!string.IsNullOrWhiteSpace(sub.TrackName))
{
psi.ArgumentList.Add("--track-name");
psi.ArgumentList.Add($"0:{sub.TrackName}");
}
if (sub.Forced)
{
psi.ArgumentList.Add("--forced-display-flag");
psi.ArgumentList.Add("0:yes");
}
if (sub.HearingImpaired)
{
psi.ArgumentList.Add("--hearing-impaired-flag");
psi.ArgumentList.Add("0:yes");
}
psi.ArgumentList.Add("--default-track-flag");
psi.ArgumentList.Add(sub.IsDefault ? "0:yes" : "0:no");
psi.ArgumentList.Add(sub.FilePath);
}
}
using var process = new Process { StartInfo = psi };
process.Start();
+105
View File
@@ -0,0 +1,105 @@
using System.IO;
namespace Futonizer.Services;
/// <summary>
/// Result of scanning an external subtitle's filename for language and
/// display hints.
/// </summary>
public readonly record struct SubtitleLanguageInfo(string Language, bool Forced, bool HearingImpaired, string TrackName);
/// <summary>
/// Best-effort language/flag detection for external subtitle files, based on
/// common filename tokens (e.g. "Show.S04E06.eng.srt", "Show.S04E06.forced.srt").
/// Never throws — falls back to an "und" (undetermined) language with no
/// track name when nothing recognisable is found.
/// </summary>
public static class SubtitleLanguageHelper
{
private static readonly Dictionary<string, string> TokenToIso6392 = new(StringComparer.OrdinalIgnoreCase)
{
["en"] = "eng", ["eng"] = "eng", ["english"] = "eng",
["ja"] = "jpn", ["jp"] = "jpn", ["jpn"] = "jpn", ["japanese"] = "jpn",
["es"] = "spa", ["spa"] = "spa", ["spanish"] = "spa", ["esp"] = "spa",
["fr"] = "fre", ["fre"] = "fre", ["fra"] = "fre", ["french"] = "fre",
["de"] = "ger", ["ger"] = "ger", ["deu"] = "ger", ["german"] = "ger",
["it"] = "ita", ["ita"] = "ita", ["italian"] = "ita",
["pt"] = "por", ["por"] = "por", ["portuguese"] = "por", ["ptbr"] = "por",
["ru"] = "rus", ["rus"] = "rus", ["russian"] = "rus",
["zh"] = "chi", ["chi"] = "chi", ["zho"] = "chi", ["chinese"] = "chi", ["cmn"] = "chi", ["yue"] = "chi",
["ko"] = "kor", ["kor"] = "kor", ["korean"] = "kor",
["ar"] = "ara", ["ara"] = "ara", ["arabic"] = "ara",
["nl"] = "dut", ["dut"] = "dut", ["nld"] = "dut", ["dutch"] = "dut",
["sv"] = "swe", ["swe"] = "swe", ["swedish"] = "swe",
["no"] = "nor", ["nor"] = "nor", ["norwegian"] = "nor",
["da"] = "dan", ["dan"] = "dan", ["danish"] = "dan",
["fi"] = "fin", ["fin"] = "fin", ["finnish"] = "fin",
["pl"] = "pol", ["pol"] = "pol", ["polish"] = "pol",
["tr"] = "tur", ["tur"] = "tur", ["turkish"] = "tur",
["vi"] = "vie", ["vie"] = "vie", ["vietnamese"] = "vie",
["th"] = "tha", ["tha"] = "tha", ["thai"] = "tha",
["id"] = "ind", ["ind"] = "ind", ["indonesian"] = "ind",
["he"] = "heb", ["heb"] = "heb", ["hebrew"] = "heb",
["hin"] = "hin", ["hindi"] = "hin",
["cs"] = "cze", ["cze"] = "cze", ["ces"] = "cze", ["czech"] = "cze",
["el"] = "gre", ["gre"] = "gre", ["ell"] = "gre", ["greek"] = "gre",
["hu"] = "hun", ["hun"] = "hun", ["hungarian"] = "hun",
["ro"] = "rum", ["rum"] = "rum", ["ron"] = "rum", ["romanian"] = "rum",
["uk"] = "ukr", ["ukr"] = "ukr", ["ukrainian"] = "ukr",
};
private static readonly Dictionary<string, string> Iso6392ToDisplayName = new(StringComparer.OrdinalIgnoreCase)
{
["eng"] = "English", ["jpn"] = "Japanese", ["spa"] = "Spanish", ["fre"] = "French",
["ger"] = "German", ["ita"] = "Italian", ["por"] = "Portuguese", ["rus"] = "Russian",
["chi"] = "Chinese", ["kor"] = "Korean", ["ara"] = "Arabic", ["dut"] = "Dutch",
["swe"] = "Swedish", ["nor"] = "Norwegian", ["dan"] = "Danish", ["fin"] = "Finnish",
["pol"] = "Polish", ["tur"] = "Turkish", ["vie"] = "Vietnamese", ["tha"] = "Thai",
["ind"] = "Indonesian", ["heb"] = "Hebrew", ["hin"] = "Hindi", ["cze"] = "Czech",
["gre"] = "Greek", ["hun"] = "Hungarian", ["rum"] = "Romanian", ["ukr"] = "Ukrainian",
};
private static readonly char[] TokenSeparators = { '.', '_', '-', ' ', '(', ')', '[', ']' };
public static SubtitleLanguageInfo Detect(string fileName)
{
string stem = Path.GetFileNameWithoutExtension(fileName);
var tokens = stem.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries);
string language = "und";
bool forced = false;
bool hearingImpaired = false;
foreach (var token in tokens)
{
if (string.Equals(token, "forced", StringComparison.OrdinalIgnoreCase))
{
forced = true;
continue;
}
if (string.Equals(token, "sdh", StringComparison.OrdinalIgnoreCase)
|| string.Equals(token, "cc", StringComparison.OrdinalIgnoreCase))
{
hearingImpaired = true;
continue;
}
if (language == "und" && TokenToIso6392.TryGetValue(token, out var iso))
language = iso;
}
string trackName = string.Empty;
if (Iso6392ToDisplayName.TryGetValue(language, out var displayName))
trackName = displayName;
if (forced || hearingImpaired)
{
var suffixes = new List<string>();
if (forced) suffixes.Add("Forced");
if (hearingImpaired) suffixes.Add("SDH");
string suffix = $" ({string.Join(", ", suffixes)})";
trackName = string.IsNullOrEmpty(trackName) ? suffix.Trim(' ', '(', ')') : trackName + suffix;
}
return new SubtitleLanguageInfo(language, forced, hearingImpaired, trackName);
}
}
+141
View File
@@ -0,0 +1,141 @@
using System.IO;
using System.Text.RegularExpressions;
namespace Futonizer.Services;
/// <summary>
/// Recognises subtitle files by extension and extracts episode information
/// from a filename — either a normalized season+episode code (e.g.
/// "S04E06") or, failing that, a bare episode number (e.g. "06", "6",
/// "16") — used to match dropped subtitle files to the video file for the
/// same episode.
/// </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);
// Explicit episode marker with no season prefix: "E06", "EP06", "Episode 06".
// Requires a non-alphanumeric character (or start of string) right before
// the marker so it doesn't fire on the "e" inside an unrelated word.
private static readonly Regex ExplicitEpisodeMarkerRegex =
new(@"(?:^|[^A-Za-z0-9])(?:episode|ep|e)[\s._-]*(\d{1,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Whole filename stem is just a number, e.g. "06", "6", "16".
private static readonly Regex BareWholeNumberRegex =
new(@"^\d{1,4}$", RegexOptions.Compiled);
// A number cleanly delimited at the very start of the stem by a
// separator, e.g. "06 - Title", "06_Title", "06.Title".
private static readonly Regex BareLeadingNumberRegex =
new(@"^(\d{1,3})(?=[\s._-])", RegexOptions.Compiled);
// A number delimited by a " - " (or "-") release-style dash separator
// that isn't at the very end of the stem, e.g. "Show - 06 - Title",
// "[Group] Show Name - 06 [1080p]". Requires whitespace immediately
// before the dash so it doesn't fire on glued tokens like "x264-06".
private static readonly Regex DashDelimitedNumberRegex =
new(@"(?<=\s-\s)(\d{1,3})(?!\d)|(?<=\s-)(\d{1,3})(?!\d)", RegexOptions.Compiled);
// A number cleanly delimited at the very end of the stem by a
// separator, e.g. "Show - 06", "Show_06", "Show.06". A "." only counts
// as a separator here when it isn't itself preceded by a digit, so
// decimal-looking tokens like audio channel counts ("DD5.1") aren't
// mistaken for an episode number.
private static readonly Regex BareTrailingNumberRegex =
new(@"(?<=[\s_-]|(?<!\d)\.)(\d{1,3})$", 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 season+episode code is found —
/// use <see cref="ExtractEpisodeNumber"/> for the season-agnostic
/// fallback (plain episode numbers like "06").
/// </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}";
}
/// <summary>
/// Extracts just the episode number from a filename, ignoring season.
/// Tries, in order: a full season+episode code (e.g. "S04E06" → 6), an
/// explicit marker with no season ("E06", "EP06", "Episode 06" → 6), or
/// a bare number that is either the entire filename stem ("06", "6",
/// "16") or cleanly delimited from the rest of the name by a separator
/// ("06 - Title", "Show - 06"). Bare numbers are capped at a few digits
/// so resolutions (1080p), years, and bitrates aren't mistaken for an
/// episode number. Returns null if nothing usable is found.
/// </summary>
public static int? ExtractEpisodeNumber(string fileName)
{
string stem = Path.GetFileNameWithoutExtension(fileName);
var seasonMatch = SeasonEpisodeRegex.Match(stem);
if (!seasonMatch.Success)
seasonMatch = SeasonXEpisodeRegex.Match(stem);
if (seasonMatch.Success && int.TryParse(seasonMatch.Groups[2].Value, out int fromSeasonCode))
return fromSeasonCode;
var markerMatch = ExplicitEpisodeMarkerRegex.Match(stem);
if (markerMatch.Success && int.TryParse(markerMatch.Groups[1].Value, out int fromMarker))
return fromMarker;
if (BareWholeNumberRegex.IsMatch(stem) && int.TryParse(stem, out int wholeStem))
return wholeStem;
var leadingMatch = BareLeadingNumberRegex.Match(stem);
if (leadingMatch.Success && int.TryParse(leadingMatch.Groups[1].Value, out int fromLeading))
return fromLeading;
var dashMatch = DashDelimitedNumberRegex.Match(stem);
if (dashMatch.Success)
{
string dashValue = dashMatch.Groups[1].Success ? dashMatch.Groups[1].Value : dashMatch.Groups[2].Value;
if (int.TryParse(dashValue, out int fromDash))
return fromDash;
}
var trailingMatch = BareTrailingNumberRegex.Match(stem);
if (trailingMatch.Success && int.TryParse(trailingMatch.Groups[1].Value, out int fromTrailing))
return fromTrailing;
return null;
}
}
+6 -2
View File
@@ -174,7 +174,7 @@ public partial class ProgressWindow : FluentWindow
.Where(t => t.Type == "subtitles" && t.Copy)
.Select(t => t.Id).ToList();
bool nothingToStrip = file.Tracks.All(t => t.Copy);
bool nothingToStrip = file.Tracks.All(t => t.Copy) && file.ExternalSubtitles.Count == 0;
var progress = new Progress<ProcessingProgress>(p =>
{
@@ -187,10 +187,14 @@ public partial class ProgressWindow : FluentWindow
: await service.StripTracksAsync(
file.FilePath, outputFilePath,
videoIds, audioIds, subIds,
file.ExternalSubtitles.ToList(),
progress, ct);
string tag = result.Success ? "[DONE]" : "[FAIL]";
AppendLog($"{tag} {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
{