Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05fbe34e20 | ||
|
|
e24a401ffb | ||
|
|
08aae68525 | ||
|
|
89bba70ee1 | ||
|
|
3903fd475a | ||
|
|
1348cb5394 | ||
|
|
3efe246a20 |
@@ -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
@@ -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.7</Version>
|
<Version>1.0.13</Version>
|
||||||
<FileVersion>1.0.7.0</FileVersion>
|
<FileVersion>1.0.13.0</FileVersion>
|
||||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+141
-18
@@ -14,11 +14,38 @@ 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
|
||||||
|
// last word, e.g. "... (1080p AMZN WEB-DL x265 Celdra)" → group="Celdra".
|
||||||
|
// Common on Amazon/streaming rips that don't set the group off with a
|
||||||
|
// "-" or its own brackets.
|
||||||
|
private static readonly Regex TrailingParenGroupRegex =
|
||||||
|
new(@"\(([^()]*)\)$", RegexOptions.Compiled);
|
||||||
|
|
||||||
// Prefix release tag: "[KAA] Show Name" → tag = "KAA"
|
// Prefix release tag: "[KAA] Show Name" → tag = "KAA"
|
||||||
private static readonly Regex PrefixTagRegex =
|
private static readonly Regex PrefixTagRegex =
|
||||||
@@ -45,10 +72,24 @@ public static class FileNameHelper
|
|||||||
new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources
|
new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources
|
||||||
new(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
|
new(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
|
||||||
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs
|
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs
|
||||||
new(@"^(FLAC|AAC|DTS|TrueHD|Atmos|AC3|EAC3|Opus|MP3)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs
|
new(@"^(FLAC|AAC|DTS(-HD)?|TrueHD|Atmos|E?AC3|DDP?|Opus|MP3)(\d(\.\d)?)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs (+ optional channel suffix, e.g. DDP5.1)
|
||||||
|
new(@"^\d\.\d$", RegexOptions.Compiled), // bare channel count, e.g. 5.1
|
||||||
new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
|
new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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>
|
||||||
@@ -56,8 +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 is stripped and promoted to a bracket prefix.
|
/// the filename stem, a group tacked on after a bracketed metadata
|
||||||
/// Takes priority over any folder-derived tag.</item>
|
/// 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
|
/// <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>
|
||||||
@@ -75,16 +120,63 @@ 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;
|
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.
|
// 2. Determine the release tag: filename suffix > folder tag.
|
||||||
string? tag = fileTag;
|
string? tag = fileTag;
|
||||||
@@ -122,29 +214,44 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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;
|
||||||
}
|
}
|
||||||
@@ -163,6 +270,7 @@ 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();
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -191,4 +299,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user