5 Commits
Author SHA1 Message Date
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
8 changed files with 319 additions and 45 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.9</Version> <Version>1.0.13</Version>
<FileVersion>1.0.9.0</FileVersion> <FileVersion>1.0.13.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();
+62 -9
View File
@@ -30,6 +30,16 @@ public static class FileNameHelper
private static readonly Regex FileSuffixTagRegex = private static readonly Regex FileSuffixTagRegex =
new(@"(?:^|[\s.])([A-Za-z0-9]+)-([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".
// Common on Amazon/streaming rips that don't set the group off with a // Common on Amazon/streaming rips that don't set the group off with a
@@ -67,6 +77,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>
@@ -74,10 +97,12 @@ 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) the show name is derived from the
/// parent folder and prepended.</item> /// parent folder and prepended.</item>
@@ -95,6 +120,12 @@ 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. "...x264-DemiHuman" → tag="DemiHuman", stem="...x264" // e.g. "...x264-DemiHuman" → tag="DemiHuman", stem="...x264"
// Only fires when the word right before the dash is itself a // Only fires when the word right before the dash is itself a
@@ -102,6 +133,7 @@ public static class FileNameHelper
// hyphenated title words are left alone. // hyphenated title words are left alone.
string? fileTag = null; string? fileTag = null;
var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt); var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt);
var bracketDashMatch = FileSuffixBracketDashTagRegex.Match(stemNoExt);
if (fileSuffixMatch.Success if (fileSuffixMatch.Success
&& IsMetadataToken(fileSuffixMatch.Groups[1].Value) && IsMetadataToken(fileSuffixMatch.Groups[1].Value)
&& !IsMetadataToken(fileSuffixMatch.Groups[2].Value)) && !IsMetadataToken(fileSuffixMatch.Groups[2].Value))
@@ -110,6 +142,16 @@ public static class FileNameHelper
stemNoExt = stemNoExt[..(fileSuffixMatch.Groups[2].Index - 1)]; stemNoExt = stemNoExt[..(fileSuffixMatch.Groups[2].Index - 1)];
fileName = stemNoExt + ext; 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 else
{ {
// 1b. Extract a release group tucked in as the last word of a // 1b. Extract a release group tucked in as the last word of a
@@ -172,12 +214,20 @@ public static class FileNameHelper
} }
} }
// 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;
@@ -258,7 +308,10 @@ public static class FileNameHelper
/// </summary> /// </summary>
private static bool IsMetadataBracket(string content) private static bool IsMetadataBracket(string content)
{ {
var parts = content.Split(new[] { ' ', '-' }, StringSplitOptions.RemoveEmptyEntries); 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); 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;
}
} }