7 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
8 changed files with 399 additions and 57 deletions
+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> <ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Futonizer</AssemblyName> <AssemblyName>Futonizer</AssemblyName>
<RootNamespace>Futonizer</RootNamespace> <RootNamespace>Futonizer</RootNamespace>
<Version>1.0.8</Version> <Version>1.0.14</Version>
<FileVersion>1.0.8.0</FileVersion> <FileVersion>1.0.14.0</FileVersion>
<ApplicationIcon>Assets\app.ico</ApplicationIcon> <ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
+2 -1
View File
@@ -149,6 +149,7 @@
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel" PreviewMouseWheel="SmoothList_PreviewMouseWheel"
PreviewKeyDown="FileQueueList_PreviewKeyDown" PreviewKeyDown="FileQueueList_PreviewKeyDown"
MouseDoubleClick="FileQueueList_MouseDoubleClick"
ItemContainerStyle="{StaticResource QueueListBoxItemStyle}" ItemContainerStyle="{StaticResource QueueListBoxItemStyle}"
SelectionChanged="FileQueueList_SelectionChanged"> SelectionChanged="FileQueueList_SelectionChanged">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
@@ -289,7 +290,7 @@
GridLinesVisibility="None" GridLinesVisibility="None"
VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.ScrollUnit="Pixel"
PreviewMouseWheel="SmoothList_PreviewMouseWheel" PreviewMouseWheel="SmoothList_PreviewMouseWheel"
PreviewKeyDown="TrackGrid_PreviewKeyDown" MouseDoubleClick="TrackGrid_MouseDoubleClick"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"> ScrollViewer.VerticalScrollBarVisibility="Auto">
<DataGrid.RowStyle> <DataGrid.RowStyle>
+64 -25
View File
@@ -266,7 +266,7 @@ public partial class MainWindow : FluentWindow
if (matchedSubtitles > 0 || unmatchedSubtitles > 0) if (matchedSubtitles > 0 || unmatchedSubtitles > 0)
{ {
StatusText.Text = 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); {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."; : $"Matched {matchedSubtitles} subtitle(s) to their episode.";
} }
} }
@@ -275,22 +275,32 @@ public partial class MainWindow : FluentWindow
/// <summary> /// <summary>
/// Attempts to attach a dropped subtitle file to whichever queued video(s) /// 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 /// share the same episode. Prefers a full season+episode code (e.g.
/// queued file when there's exactly one and no episode code could be /// S04E06) when the subtitle's filename has one; otherwise falls back to
/// extracted from the subtitle's filename. Returns false if no target /// matching by bare episode number alone (e.g. "06", "6", "16", "E06"),
/// could be determined, in which case the file is kept pending until a /// season-agnostic. Falls back further to the single queued file when
/// matching video is dropped later. /// 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> /// </summary>
private bool MatchSubtitleToQueue(string subtitlePath) private bool MatchSubtitleToQueue(string subtitlePath)
{ {
string subFileName = Path.GetFileName(subtitlePath); string subFileName = Path.GetFileName(subtitlePath);
string? code = SubtitleMatcher.ExtractEpisodeCode(subFileName); string? subCode = SubtitleMatcher.ExtractEpisodeCode(subFileName);
int? subEpisodeNumber = subCode == null ? SubtitleMatcher.ExtractEpisodeNumber(subFileName) : null;
List<QueuedFileItem> targets; List<QueuedFileItem> targets;
if (code != null) if (subCode != null)
{ {
targets = _fileQueue targets = _fileQueue
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), code, StringComparison.OrdinalIgnoreCase)) .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(); .ToList();
} }
else if (_fileQueue.Count == 1) else if (_fileQueue.Count == 1)
@@ -312,19 +322,29 @@ public partial class MainWindow : FluentWindow
/// <summary> /// <summary>
/// Called whenever a new video is added to the queue: checks the pending /// Called whenever a new video is added to the queue: checks the pending
/// (previously unmatched) subtitle files for one sharing this video's /// (previously unmatched) subtitle files for one matching this video's
/// episode code, and attaches + removes any matches from the pending list. /// 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> /// </summary>
private void MatchPendingSubtitlesToFile(QueuedFileItem item) private void MatchPendingSubtitlesToFile(QueuedFileItem item)
{ {
if (_pendingSubtitles.Count == 0) return; if (_pendingSubtitles.Count == 0) return;
string? code = SubtitleMatcher.ExtractEpisodeCode(item.FileName); string? videoCode = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
if (code == null) return; int? videoEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(item.FileName);
if (videoCode == null && videoEpisodeNumber == null) return;
var matches = _pendingSubtitles var matches = _pendingSubtitles.Where(p =>
.Where(p => string.Equals(SubtitleMatcher.ExtractEpisodeCode(Path.GetFileName(p)), code, StringComparison.OrdinalIgnoreCase)) {
.ToList(); 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) foreach (var match in matches)
{ {
@@ -519,13 +539,6 @@ public partial class MainWindow : FluentWindow
/// </summary> /// </summary>
private void FileQueueList_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) private void FileQueueList_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{ {
if (e.Key == System.Windows.Input.Key.Space)
{
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
return;
}
if (e.Key != System.Windows.Input.Key.Delete) return; if (e.Key != System.Windows.Input.Key.Delete) return;
var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList(); var selected = FileQueueList.SelectedItems.Cast<QueuedFileItem>().ToList();
@@ -545,13 +558,39 @@ public partial class MainWindow : FluentWindow
/// Windows has associated with .mkv files (typically the default video /// Windows has associated with .mkv files (typically the default video
/// player), so the user can quickly preview a file without leaving the app. /// player), so the user can quickly preview a file without leaving the app.
/// </summary> /// </summary>
private void 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(); OpenSelectedFileInDefaultPlayer();
e.Handled = true; e.Handled = true;
} }
/// <summary>
/// Same as <see cref="FileQueueList_MouseDoubleClick"/>, but for the track
/// table. Ignores double-clicks on the "Copy" checkbox column so toggling
/// it rapidly doesn't also launch the default player.
/// </summary>
private void TrackGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject source && FindAncestor<CheckBox>(source) != null) return;
OpenSelectedFileInDefaultPlayer();
e.Handled = true;
}
/// <summary>
/// Walks up the visual tree from <paramref name="source"/> looking for an
/// ancestor (or the element itself) of type <typeparamref name="T"/>.
/// </summary>
private static T? FindAncestor<T>(DependencyObject source) where T : DependencyObject
{
var current = source;
while (current != null)
{
if (current is T typed) return typed;
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
}
return null;
}
private void OpenSelectedFileInDefaultPlayer() private void OpenSelectedFileInDefaultPlayer()
{ {
if (FileQueueList.SelectedItem is not QueuedFileItem item) return; if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
+3 -2
View File
@@ -6,8 +6,9 @@ namespace Futonizer.Models;
/// <summary> /// <summary>
/// Represents one external subtitle file that was drag-and-dropped in and /// Represents one external subtitle file that was drag-and-dropped in and
/// matched to a <see cref="QueuedFileItem"/> by episode code (e.g. S04E06), /// matched to a <see cref="QueuedFileItem"/> by episode (e.g. "S04E06", or
/// to be muxed in as an additional subtitle track when the file is processed. /// just a bare episode number like "06"), to be muxed in as an additional
/// subtitle track when the file is processed.
/// </summary> /// </summary>
public class ExternalSubtitleTrack : INotifyPropertyChanged public class ExternalSubtitleTrack : INotifyPropertyChanged
{ {
+3 -2
View File
@@ -45,8 +45,9 @@ public class QueuedFileItem : INotifyPropertyChanged
/// <summary> /// <summary>
/// External subtitle files (dropped separately and matched to this file by /// External subtitle files (dropped separately and matched to this file by
/// episode code, e.g. S04E06) that will be muxed in as additional /// episode e.g. "S04E06", or just a bare episode number like "06") that
/// subtitle tracks when this file is processed. /// will be muxed in as additional subtitle tracks when this file is
/// processed.
/// </summary> /// </summary>
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new(); public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
+142 -21
View File
@@ -14,11 +14,31 @@ public static class FileNameHelper
private static readonly Regex FolderSuffixTagRegex = private static readonly Regex FolderSuffixTagRegex =
new(@"-([A-Z][A-Z0-9]{1,7})$", RegexOptions.Compiled); 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" // File suffix release tag: "…HEVC-DemiHuman" → tag = "DemiHuman"
// Mixed case, 3-21 chars (no dots/spaces), at end of filename stem. // Mixed case, 3-21 chars (no dots/spaces), at end of filename stem.
// Min 3 chars avoids false-positives like "-v2" or "-HD". // 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 = private static readonly Regex FileSuffixTagRegex =
new(@"-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled); 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 // Trailing parenthesised metadata block ending in the release group as its
// last word, e.g. "... (1080p AMZN WEB-DL x265 Celdra)" → group="Celdra". // last word, e.g. "... (1080p AMZN WEB-DL x265 Celdra)" → group="Celdra".
@@ -40,6 +60,14 @@ public static class FileNameHelper
private static readonly Regex EpisodeStartRegex = private static readonly Regex EpisodeStartRegex =
new(@"^(\d+|S\d+E\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); 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 // 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 =
@@ -57,6 +85,19 @@ public static class FileNameHelper
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
]; ];
// 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> /// <summary>
@@ -64,13 +105,16 @@ 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, or a release group tucked in as the last word /// the filename stem, a group tacked on after a bracketed metadata
/// of a trailing <c>(...)</c> metadata block (e.g. Amazon rips like /// block (e.g. <c>[h264]-NTb</c>), or a release group tucked in as
/// <c>(1080p AMZN WEB-DL x265 Celdra)</c>), is stripped and promoted /// the last word of a trailing <c>(...)</c> metadata block (e.g.
/// to a bracket prefix. Takes priority over any folder-derived tag.</item> /// 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), or with a bare episode number
/// parent folder and prepended.</item> /// (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 /// <item>Release-tag prefix — a <c>[TAG]</c> bracket prefix, sourced from
/// the filename suffix, the folder suffix, or the folder prefix, /// the filename suffix, the folder suffix, or the folder prefix,
/// in that priority order.</item> /// in that priority order.</item>
@@ -85,14 +129,36 @@ public static class FileNameHelper
string? folder = Path.GetDirectoryName(sourceFilePath); string? folder = Path.GetDirectoryName(sourceFilePath);
string folderName = string.IsNullOrEmpty(folder) ? "" : (Path.GetFileName(folder) ?? ""); string folderName = string.IsNullOrEmpty(folder) ? "" : (Path.GetFileName(folder) ?? "");
// Capture the file's own pre-existing bracket prefix (if any) before any
// transformation below, so we never stack a second/different tag on top
// of a file that already carries its own release-group prefix.
var originalPrefixMatch = PrefixTagRegex.Match(stemNoExt);
string? originalPrefixTag = originalPrefixMatch.Success ? originalPrefixMatch.Groups[1].Value : null;
// 1. Extract in-filename suffix tag and remove it from the stem. // 1. Extract in-filename suffix tag and remove it from the stem.
// e.g. "...HEVC-DemiHuman" → tag="DemiHuman", stem="...HEVC" // 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; string? fileTag = null;
var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt); var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt);
if (fileSuffixMatch.Success) var bracketDashMatch = FileSuffixBracketDashTagRegex.Match(stemNoExt);
if (fileSuffixMatch.Success
&& IsMetadataToken(fileSuffixMatch.Groups[1].Value)
&& !IsMetadataToken(fileSuffixMatch.Groups[2].Value))
{ {
fileTag = fileSuffixMatch.Groups[1].Value; fileTag = fileSuffixMatch.Groups[2].Value;
stemNoExt = stemNoExt[..fileSuffixMatch.Index]; 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; fileName = stemNoExt + ext;
} }
else else
@@ -154,32 +220,58 @@ public static class FileNameHelper
} }
} }
} }
// 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;
}
} }
} }
// 4. Prepend release tag if not already present. // 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)) if (!string.IsNullOrEmpty(tag))
{ {
string tagPrefix = $"[{tag}]"; bool differsFromOriginalTag = originalPrefixTag != null
if (!fileName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase)) && !string.Equals(originalPrefixTag, tag, StringComparison.OrdinalIgnoreCase);
fileName = tagPrefix + " " + fileName;
if (!differsFromOriginalTag)
{
string tagPrefix = $"[{tag}]";
if (!fileName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase))
fileName = tagPrefix + " " + fileName;
}
} }
return fileName; return fileName;
} }
/// <summary> /// <summary>
/// Extracts the release tag string (without brackets) from a folder name. /// Extracts the release tag string (without brackets) from a folder name,
/// Suffix pattern (<c>-TAG</c>) takes priority over prefix pattern /// checking (in priority order): a dash suffix (<c>-TAG</c>), a bracketed
/// (<c>[TAG]</c>). Returns <c>null</c> when neither is present. /// 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> /// </summary>
public static string? ExtractReleaseTag(string folderName) public static string? ExtractReleaseTag(string folderName)
{ {
var suffixMatch = FolderSuffixTagRegex.Match(folderName); var suffixMatch = FolderSuffixTagRegex.Match(folderName);
if (suffixMatch.Success) return suffixMatch.Groups[1].Value; 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); 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; return null;
} }
@@ -198,8 +290,22 @@ public static class FileNameHelper
{ {
// Strip release tags first. // Strip release tags first.
string name = FolderSuffixTagRegex.Replace(folderName, "").Trim(); string name = FolderSuffixTagRegex.Replace(folderName, "").Trim();
name = FolderSuffixBracketTagRegex.Replace(name, "").Trim();
name = PrefixTagRegex.Replace(name, "").Trim(); name = PrefixTagRegex.Replace(name, "").Trim();
// 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. // Nothing left, or what remains immediately starts with a bracket — no show name.
if (string.IsNullOrEmpty(name) || name[0] == '[') return string.Empty; if (string.IsNullOrEmpty(name) || name[0] == '[') return string.Empty;
@@ -226,4 +332,19 @@ public static class FileNameHelper
if (pattern.IsMatch(token)) return true; if (pattern.IsMatch(token)) return true;
return false; 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);
}
} }
+84 -4
View File
@@ -4,9 +4,11 @@ using System.Text.RegularExpressions;
namespace Futonizer.Services; namespace Futonizer.Services;
/// <summary> /// <summary>
/// Recognises subtitle files by extension and extracts a normalized episode /// Recognises subtitle files by extension and extracts episode information
/// code (e.g. "S04E06") from a filename, used to match dropped subtitle /// from a filename — either a normalized season+episode code (e.g.
/// files to the video file with the same episode code. /// "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> /// </summary>
public static class SubtitleMatcher public static class SubtitleMatcher
{ {
@@ -39,11 +41,43 @@ public static class SubtitleMatcher
private static readonly Regex SeasonXEpisodeRegex = private static readonly Regex SeasonXEpisodeRegex =
new(@"(?<![\d.])(\d{1,2})x(\d{2,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled); 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> /// <summary>
/// Extracts and normalizes the episode code from a filename (e.g. /// Extracts and normalizes the episode code from a filename (e.g.
/// "Show.S4E6.1080p.mkv" and "Show - 04x06 - Title.srt" both return /// "Show.S4E6.1080p.mkv" and "Show - 04x06 - Title.srt" both return
/// "S04E06"), so files can be matched regardless of zero-padding or /// "S04E06"), so files can be matched regardless of zero-padding or
/// separator style. Returns null if no episode code is found. /// 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> /// </summary>
public static string? ExtractEpisodeCode(string fileName) public static string? ExtractEpisodeCode(string fileName)
{ {
@@ -58,4 +92,50 @@ public static class SubtitleMatcher
return $"S{season:D2}E{episode:D2}"; 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;
}
} }