Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05fbe34e20 | ||
|
|
e24a401ffb | ||
|
|
08aae68525 | ||
|
|
89bba70ee1 | ||
|
|
3903fd475a | ||
|
|
1348cb5394 | ||
|
|
3efe246a20 | ||
|
|
10d7c14444 | ||
|
|
c7c125d40b | ||
|
|
304d9ad047 | ||
|
|
11a519d309 | ||
|
|
8b94fefe0f | ||
|
|
02ce7bb872 | ||
|
|
e61cb5e709 | ||
|
|
a322b6bb00 |
@@ -1,5 +1,6 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
publish/
|
||||||
*.user
|
*.user
|
||||||
history.json
|
history.json
|
||||||
settings.json
|
settings.json
|
||||||
|
|||||||
@@ -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.2</Version>
|
<Version>1.0.13</Version>
|
||||||
<FileVersion>1.0.2.0</FileVersion>
|
<FileVersion>1.0.13.0</FileVersion>
|
||||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
+75
-4
@@ -137,7 +137,7 @@
|
|||||||
<Grid Grid.Row="1">
|
<Grid Grid.Row="1">
|
||||||
<TextBlock x:Name="DropHint"
|
<TextBlock x:Name="DropHint"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
Text="Drop .mkv files onto this window"
|
Text="Drop .mkv files (and subtitles) onto this window"
|
||||||
Opacity="0.4" FontSize="14" TextAlignment="Center"
|
Opacity="0.4" FontSize="14" TextAlignment="Center"
|
||||||
IsHitTestVisible="False"/>
|
IsHitTestVisible="False"/>
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -158,6 +159,7 @@
|
|||||||
<ColumnDefinition Width="16"/>
|
<ColumnDefinition Width="16"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="18"/>
|
<ColumnDefinition Width="18"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
@@ -175,16 +177,25 @@
|
|||||||
Foreground="#F44336" VerticalAlignment="Center"
|
Foreground="#F44336" VerticalAlignment="Center"
|
||||||
Visibility="{Binding IsIncorrect, Converter={StaticResource BoolToVis}}"/>
|
Visibility="{Binding IsIncorrect, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Column="1" Text="{Binding FileName}"
|
<TextBlock Grid.Column="1" Text="{Binding DisplayFileName}"
|
||||||
FontSize="12" VerticalAlignment="Center" Margin="6,0"
|
FontSize="12" VerticalAlignment="Center" Margin="6,0"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
|
||||||
<TextBlock Grid.Column="2" Text="{Binding FileSizeDisplay}"
|
<Border Grid.Column="2" CornerRadius="8" Padding="5,1" Margin="2,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="{DynamicResource AccentFillColorSecondaryBrush}"
|
||||||
|
Visibility="{Binding HasExternalSubtitles, Converter={StaticResource BoolToVis}}"
|
||||||
|
ToolTip="{Binding ExternalSubtitlesTooltip}">
|
||||||
|
<TextBlock Text="{Binding ExternalSubtitles.Count, StringFormat={}+{0} sub}"
|
||||||
|
FontSize="9" FontWeight="SemiBold"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding FileSizeDisplay}"
|
||||||
FontSize="11" VerticalAlignment="Center" Margin="4,0"
|
FontSize="11" VerticalAlignment="Center" Margin="4,0"
|
||||||
Opacity="0.6"
|
Opacity="0.6"
|
||||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||||
|
|
||||||
<Button Grid.Column="3" Content="×"
|
<Button Grid.Column="4" Content="×"
|
||||||
Tag="{Binding}"
|
Tag="{Binding}"
|
||||||
Click="RemoveFile_Click"
|
Click="RemoveFile_Click"
|
||||||
Background="Transparent" BorderThickness="0"
|
Background="Transparent" BorderThickness="0"
|
||||||
@@ -209,6 +220,55 @@
|
|||||||
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
|
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
|
||||||
BorderThickness="1">
|
BorderThickness="1">
|
||||||
<Grid>
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- External subtitles matched to the selected file -->
|
||||||
|
<Border x:Name="ExternalSubsPanel" Grid.Row="0" Margin="10,8,10,0"
|
||||||
|
Padding="8,6" CornerRadius="6" Visibility="Collapsed"
|
||||||
|
Background="{DynamicResource ControlFillColorSecondaryBrush}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="External subtitles to mux in" FontSize="11"
|
||||||
|
FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
|
||||||
|
<ItemsControl x:Name="ExternalSubsList">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
|
||||||
|
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="4"
|
||||||
|
Margin="0,0,6,6" Padding="6,3">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding LanguageDisplay}" FontSize="11"
|
||||||
|
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding FileName}" FontSize="11" Opacity="0.6"
|
||||||
|
Margin="6,0,0,0" VerticalAlignment="Center"
|
||||||
|
MaxWidth="220" TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="DEFAULT" FontSize="9" FontWeight="Bold"
|
||||||
|
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
|
||||||
|
Margin="6,0,0,0" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding IsDefault, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Button Content="×" Tag="{Binding}"
|
||||||
|
Click="RemoveExternalSubtitle_Click"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Padding="6,0,0,0" Cursor="Hand" FontSize="13"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1">
|
||||||
<TextBlock x:Name="NoSelectionHint"
|
<TextBlock x:Name="NoSelectionHint"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
Text="Select a file to view its tracks"
|
Text="Select a file to view its tracks"
|
||||||
@@ -230,6 +290,7 @@
|
|||||||
GridLinesVisibility="None"
|
GridLinesVisibility="None"
|
||||||
VirtualizingPanel.ScrollUnit="Pixel"
|
VirtualizingPanel.ScrollUnit="Pixel"
|
||||||
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
|
PreviewMouseWheel="SmoothList_PreviewMouseWheel"
|
||||||
|
MouseDoubleClick="TrackGrid_MouseDoubleClick"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Auto">
|
ScrollViewer.VerticalScrollBarVisibility="Auto">
|
||||||
<DataGrid.RowStyle>
|
<DataGrid.RowStyle>
|
||||||
@@ -327,9 +388,19 @@
|
|||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Processing overlay: dims the entire content area while stripping is running -->
|
||||||
|
<Border x:Name="ProcessingOverlay"
|
||||||
|
Grid.ColumnSpan="3"
|
||||||
|
Background="#99000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
IsHitTestVisible="True"
|
||||||
|
Panel.ZIndex="10"/>
|
||||||
|
|
||||||
<!-- Loading overlay: blocks interaction until every queued file has finished loading -->
|
<!-- Loading overlay: blocks interaction until every queued file has finished loading -->
|
||||||
<Border x:Name="LoadingOverlay"
|
<Border x:Name="LoadingOverlay"
|
||||||
Grid.ColumnSpan="3"
|
Grid.ColumnSpan="3"
|
||||||
|
|||||||
+211
-57
@@ -2,7 +2,6 @@ using System.Collections.ObjectModel;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
@@ -21,6 +20,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
private AppSettings _settings = new();
|
private AppSettings _settings = new();
|
||||||
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
|
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
|
||||||
|
private readonly List<string> _pendingSubtitles = new();
|
||||||
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
|
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
|
||||||
private bool _isProcessing;
|
private bool _isProcessing;
|
||||||
private bool _propagatingSelection;
|
private bool _propagatingSelection;
|
||||||
@@ -78,42 +78,6 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If the file's parent folder has a bracketed prefix (e.g. "[neoHEVC] ")
|
|
||||||
/// and the file itself doesn't, renames the file on disk to include that
|
|
||||||
/// prefix and returns the new path. Returns the original path unchanged if
|
|
||||||
/// the condition isn't met or the rename fails.
|
|
||||||
/// </summary>
|
|
||||||
private static string TryApplyFolderPrefix(string filePath)
|
|
||||||
{
|
|
||||||
string? folder = Path.GetDirectoryName(filePath);
|
|
||||||
if (string.IsNullOrEmpty(folder)) return filePath;
|
|
||||||
|
|
||||||
string folderName = Path.GetFileName(folder);
|
|
||||||
var match = Regex.Match(folderName, @"^\[.+?\] ");
|
|
||||||
if (!match.Success) return filePath;
|
|
||||||
|
|
||||||
string prefix = match.Value;
|
|
||||||
string fileName = Path.GetFileName(filePath);
|
|
||||||
|
|
||||||
if (fileName.StartsWith(prefix, StringComparison.Ordinal)) return filePath;
|
|
||||||
|
|
||||||
string newFileName = prefix + fileName;
|
|
||||||
string newFilePath = Path.Combine(folder, newFileName);
|
|
||||||
|
|
||||||
if (File.Exists(newFilePath)) return filePath;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Move(filePath, newFilePath);
|
|
||||||
return newFilePath;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RestartApplication()
|
private static void RestartApplication()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -168,6 +132,15 @@ public partial class MainWindow : FluentWindow
|
|||||||
RunButton.ToolTip = allCorrect
|
RunButton.ToolTip = allCorrect
|
||||||
? null
|
? null
|
||||||
: "Every file in the queue needs exactly one audio and one subtitle track selected before stripping.";
|
: "Every file in the queue needs exactly one audio and one subtitle track selected before stripping.";
|
||||||
|
|
||||||
|
UpdateRunButtonLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateRunButtonLabel()
|
||||||
|
{
|
||||||
|
bool anyRename = _fileQueue.Any(f =>
|
||||||
|
!string.Equals(f.DisplayFileName, f.FileName, StringComparison.Ordinal));
|
||||||
|
RunButton.Content = anyRename ? "Rename and Strip Tracks" : "Strip Tracks";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void QueuedFileItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
@@ -224,10 +197,9 @@ public partial class MainWindow : FluentWindow
|
|||||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||||
{
|
{
|
||||||
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
|
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||||
bool hasMkv = paths.Any(p =>
|
bool hasAcceptedFile = paths.Any(p =>
|
||||||
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
|
File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
|
||||||
&& File.Exists(p));
|
e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
|
||||||
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -236,6 +208,9 @@ public partial class MainWindow : FluentWindow
|
|||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsMkvFile(string path)
|
||||||
|
=> string.Equals(Path.GetExtension(path), ".mkv", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private void Window_Drop(object sender, DragEventArgs e)
|
private void Window_Drop(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
||||||
@@ -243,31 +218,178 @@ public partial class MainWindow : FluentWindow
|
|||||||
Activate();
|
Activate();
|
||||||
|
|
||||||
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
|
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||||
var newFiles = paths
|
|
||||||
.Where(p => string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
|
var newVideoPaths = paths
|
||||||
|
.Where(p => IsMkvFile(p)
|
||||||
&& File.Exists(p)
|
&& File.Exists(p)
|
||||||
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
|
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var addedPaths = new List<string>();
|
var subtitlePaths = paths
|
||||||
foreach (var path in newFiles)
|
.Where(p => SubtitleMatcher.IsSubtitleFile(p) && File.Exists(p))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var newItems = new List<QueuedFileItem>();
|
||||||
|
foreach (var path in newVideoPaths)
|
||||||
{
|
{
|
||||||
string effectivePath = TryApplyFolderPrefix(path);
|
var item = new QueuedFileItem(path);
|
||||||
var item = new QueuedFileItem(effectivePath);
|
|
||||||
item.PropertyChanged += QueuedFileItem_PropertyChanged;
|
item.PropertyChanged += QueuedFileItem_PropertyChanged;
|
||||||
_fileQueue.Add(item);
|
_fileQueue.Add(item);
|
||||||
|
newItems.Add(item);
|
||||||
_ = ScanFileItemAsync(item);
|
_ = ScanFileItemAsync(item);
|
||||||
addedPaths.Add(effectivePath);
|
}
|
||||||
|
|
||||||
|
// Newly-added videos might satisfy subtitles dropped in earlier (or
|
||||||
|
// in this same batch, if the subtitle sorts before its video).
|
||||||
|
foreach (var item in newItems)
|
||||||
|
MatchPendingSubtitlesToFile(item);
|
||||||
|
|
||||||
|
int matchedSubtitles = 0;
|
||||||
|
foreach (var subPath in subtitlePaths)
|
||||||
|
{
|
||||||
|
if (MatchSubtitleToQueue(subPath))
|
||||||
|
matchedSubtitles++;
|
||||||
|
else if (!_pendingSubtitles.Contains(subPath, StringComparer.OrdinalIgnoreCase))
|
||||||
|
_pendingSubtitles.Add(subPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateLoadingOverlay();
|
UpdateLoadingOverlay();
|
||||||
|
|
||||||
if (addedPaths.Count > 0)
|
if (newItems.Count > 0)
|
||||||
{
|
{
|
||||||
var first = _fileQueue.FirstOrDefault(q =>
|
var first = _fileQueue.FirstOrDefault(q =>
|
||||||
string.Equals(q.FilePath, addedPaths[0], StringComparison.OrdinalIgnoreCase));
|
string.Equals(q.FilePath, newVideoPaths[0], StringComparison.OrdinalIgnoreCase));
|
||||||
if (first != null) FileQueueList.SelectedItem = first;
|
if (first != null) FileQueueList.SelectedItem = first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int unmatchedSubtitles = subtitlePaths.Count - matchedSubtitles;
|
||||||
|
if (matchedSubtitles > 0 || unmatchedSubtitles > 0)
|
||||||
|
{
|
||||||
|
StatusText.Text = unmatchedSubtitles > 0
|
||||||
|
? $"Matched {matchedSubtitles} subtitle(s); {unmatchedSubtitles} couldn't be matched to an episode (S04E06, E06, or plain episode number like 06/6/16)."
|
||||||
|
: $"Matched {matchedSubtitles} subtitle(s) to their episode.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subtitle matching ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to attach a dropped subtitle file to whichever queued video(s)
|
||||||
|
/// share the same episode. Prefers a full season+episode code (e.g.
|
||||||
|
/// S04E06) when the subtitle's filename has one; otherwise falls back to
|
||||||
|
/// matching by bare episode number alone (e.g. "06", "6", "16", "E06"),
|
||||||
|
/// season-agnostic. Falls back further to the single queued file when
|
||||||
|
/// there's exactly one and no episode info at all could be extracted
|
||||||
|
/// from the subtitle's filename. Returns false if no target could be
|
||||||
|
/// determined, in which case the file is kept pending until a matching
|
||||||
|
/// video is dropped later.
|
||||||
|
/// </summary>
|
||||||
|
private bool MatchSubtitleToQueue(string subtitlePath)
|
||||||
|
{
|
||||||
|
string subFileName = Path.GetFileName(subtitlePath);
|
||||||
|
string? subCode = SubtitleMatcher.ExtractEpisodeCode(subFileName);
|
||||||
|
int? subEpisodeNumber = subCode == null ? SubtitleMatcher.ExtractEpisodeNumber(subFileName) : null;
|
||||||
|
|
||||||
|
List<QueuedFileItem> targets;
|
||||||
|
if (subCode != null)
|
||||||
|
{
|
||||||
|
targets = _fileQueue
|
||||||
|
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), subCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else if (subEpisodeNumber != null)
|
||||||
|
{
|
||||||
|
targets = _fileQueue
|
||||||
|
.Where(q => SubtitleMatcher.ExtractEpisodeNumber(q.FileName) == subEpisodeNumber)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else if (_fileQueue.Count == 1)
|
||||||
|
{
|
||||||
|
targets = new List<QueuedFileItem> { _fileQueue[0] };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targets = new List<QueuedFileItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targets.Count == 0) return false;
|
||||||
|
|
||||||
|
foreach (var target in targets)
|
||||||
|
AttachExternalSubtitle(target, subtitlePath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called whenever a new video is added to the queue: checks the pending
|
||||||
|
/// (previously unmatched) subtitle files for one matching this video's
|
||||||
|
/// episode — by full season+episode code when both have one, or by bare
|
||||||
|
/// episode number otherwise — and attaches + removes any matches from
|
||||||
|
/// the pending list.
|
||||||
|
/// </summary>
|
||||||
|
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
|
||||||
|
{
|
||||||
|
if (_pendingSubtitles.Count == 0) return;
|
||||||
|
|
||||||
|
string? videoCode = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
|
||||||
|
int? videoEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(item.FileName);
|
||||||
|
if (videoCode == null && videoEpisodeNumber == null) return;
|
||||||
|
|
||||||
|
var matches = _pendingSubtitles.Where(p =>
|
||||||
|
{
|
||||||
|
string subName = Path.GetFileName(p);
|
||||||
|
string? subCode = SubtitleMatcher.ExtractEpisodeCode(subName);
|
||||||
|
if (subCode != null)
|
||||||
|
return videoCode != null && string.Equals(subCode, videoCode, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
int? subEpisodeNumber = SubtitleMatcher.ExtractEpisodeNumber(subName);
|
||||||
|
return subEpisodeNumber != null && subEpisodeNumber == videoEpisodeNumber;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
foreach (var match in matches)
|
||||||
|
{
|
||||||
|
AttachExternalSubtitle(item, match);
|
||||||
|
_pendingSubtitles.Remove(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AttachExternalSubtitle(QueuedFileItem target, string subtitlePath)
|
||||||
|
{
|
||||||
|
if (target.ExternalSubtitles.Any(s => string.Equals(s.FilePath, subtitlePath, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var info = SubtitleLanguageHelper.Detect(Path.GetFileName(subtitlePath));
|
||||||
|
var sub = new ExternalSubtitleTrack(subtitlePath)
|
||||||
|
{
|
||||||
|
Language = info.Language,
|
||||||
|
TrackName = info.TrackName,
|
||||||
|
Forced = info.Forced,
|
||||||
|
HearingImpaired = info.HearingImpaired,
|
||||||
|
IsDefault = target.ExternalSubtitles.Count == 0,
|
||||||
|
};
|
||||||
|
target.ExternalSubtitles.Add(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveExternalSubtitle_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not System.Windows.Controls.Button btn || btn.Tag is not ExternalSubtitleTrack sub) return;
|
||||||
|
|
||||||
|
var owner = _fileQueue.FirstOrDefault(f => f.ExternalSubtitles.Contains(sub));
|
||||||
|
if (owner == null) return;
|
||||||
|
|
||||||
|
bool wasDefault = sub.IsDefault;
|
||||||
|
owner.ExternalSubtitles.Remove(sub);
|
||||||
|
|
||||||
|
if (wasDefault)
|
||||||
|
{
|
||||||
|
var next = owner.ExternalSubtitles.FirstOrDefault();
|
||||||
|
if (next != null) next.IsDefault = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReferenceEquals(FileQueueList.SelectedItem, owner))
|
||||||
|
ExternalSubsPanel.Visibility = owner.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File scanning ─────────────────────────────────────────────────────────
|
// ── File scanning ─────────────────────────────────────────────────────────
|
||||||
@@ -417,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();
|
||||||
@@ -443,6 +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 FileQueueList_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
OpenSelectedFileInDefaultPlayer();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same as <see cref="FileQueueList_MouseDoubleClick"/>, but for the track
|
||||||
|
/// table. Ignores double-clicks on the "Copy" checkbox column so toggling
|
||||||
|
/// it rapidly doesn't also launch the default player.
|
||||||
|
/// </summary>
|
||||||
|
private void TrackGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OriginalSource is DependencyObject source && FindAncestor<CheckBox>(source) != null) return;
|
||||||
|
OpenSelectedFileInDefaultPlayer();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks up the visual tree from <paramref name="source"/> looking for an
|
||||||
|
/// ancestor (or the element itself) of type <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static T? FindAncestor<T>(DependencyObject source) where T : DependencyObject
|
||||||
|
{
|
||||||
|
var current = source;
|
||||||
|
while (current != null)
|
||||||
|
{
|
||||||
|
if (current is T typed) return typed;
|
||||||
|
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private void OpenSelectedFileInDefaultPlayer()
|
private void OpenSelectedFileInDefaultPlayer()
|
||||||
{
|
{
|
||||||
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
|
if (FileQueueList.SelectedItem is not QueuedFileItem item) return;
|
||||||
@@ -481,12 +629,16 @@ public partial class MainWindow : FluentWindow
|
|||||||
TrackGrid.ItemsSource = file.Tracks;
|
TrackGrid.ItemsSource = file.Tracks;
|
||||||
TrackGrid.Visibility = Visibility.Visible;
|
TrackGrid.Visibility = Visibility.Visible;
|
||||||
NoSelectionHint.Visibility = Visibility.Collapsed;
|
NoSelectionHint.Visibility = Visibility.Collapsed;
|
||||||
|
ExternalSubsList.ItemsSource = file.ExternalSubtitles;
|
||||||
|
ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
TrackGrid.ItemsSource = null;
|
TrackGrid.ItemsSource = null;
|
||||||
TrackGrid.Visibility = Visibility.Collapsed;
|
TrackGrid.Visibility = Visibility.Collapsed;
|
||||||
NoSelectionHint.Visibility = Visibility.Visible;
|
NoSelectionHint.Visibility = Visibility.Visible;
|
||||||
|
ExternalSubsList.ItemsSource = null;
|
||||||
|
ExternalSubsPanel.Visibility = Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +685,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
RunButton.IsEnabled = false;
|
RunButton.IsEnabled = false;
|
||||||
StatusText.Text = string.Empty;
|
StatusText.Text = string.Empty;
|
||||||
|
ProcessingOverlay.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
|
var progressWin = new ProgressWindow(readyFiles, _settings.MkvMergePath, _settings.OutputFolder, MaxConcurrentStrips)
|
||||||
{
|
{
|
||||||
@@ -541,6 +694,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
progressWin.Closed += (_, _) =>
|
progressWin.Closed += (_, _) =>
|
||||||
{
|
{
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
|
ProcessingOverlay.Visibility = Visibility.Collapsed;
|
||||||
if (progressWin.ShouldClearQueue)
|
if (progressWin.ShouldClearQueue)
|
||||||
{
|
{
|
||||||
foreach (var item in _fileQueue)
|
foreach (var item in _fileQueue)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Futonizer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one external subtitle file that was drag-and-dropped in and
|
||||||
|
/// matched to a <see cref="QueuedFileItem"/> by episode (e.g. "S04E06", or
|
||||||
|
/// just a bare episode number like "06"), to be muxed in as an additional
|
||||||
|
/// subtitle track when the file is processed.
|
||||||
|
/// </summary>
|
||||||
|
public class ExternalSubtitleTrack : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private bool _isDefault;
|
||||||
|
|
||||||
|
public string FilePath { get; }
|
||||||
|
public string FileName => Path.GetFileName(FilePath);
|
||||||
|
|
||||||
|
/// <summary>Uppercased extension without the dot, e.g. "SRT", "ASS", "SUP".</summary>
|
||||||
|
public string FormatDisplay => Path.GetExtension(FilePath).TrimStart('.').ToUpperInvariant();
|
||||||
|
|
||||||
|
/// <summary>ISO 639-2 language code detected from the filename, or "und" if unknown.</summary>
|
||||||
|
public string Language { get; init; } = "und";
|
||||||
|
|
||||||
|
/// <summary>Track name to embed in the output file; may be empty.</summary>
|
||||||
|
public string TrackName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Forced { get; init; }
|
||||||
|
public bool HearingImpaired { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this is the subtitle track flagged default in the output.
|
||||||
|
/// Exactly one external subtitle per file should have this set at a time.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault
|
||||||
|
{
|
||||||
|
get => _isDefault;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isDefault == value) return;
|
||||||
|
_isDefault = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Short "FORMAT · LANG" label shown as a chip in the UI.</summary>
|
||||||
|
public string LanguageDisplay => $"{FormatDisplay} · {(string.Equals(Language, "und", StringComparison.OrdinalIgnoreCase) ? "UND" : Language.ToUpperInvariant())}";
|
||||||
|
|
||||||
|
public ExternalSubtitleTrack(string filePath)
|
||||||
|
{
|
||||||
|
FilePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using Futonizer.Services;
|
||||||
|
|
||||||
namespace Futonizer.Models;
|
namespace Futonizer.Models;
|
||||||
|
|
||||||
@@ -32,9 +33,30 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string FilePath { get; }
|
public string FilePath { get; }
|
||||||
public string FileName => Path.GetFileName(FilePath);
|
public string FileName => Path.GetFileName(FilePath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The filename as it will appear after rename adjustments (release-tag prefix
|
||||||
|
/// and/or show-name injection). Equals <see cref="FileName"/> when no
|
||||||
|
/// changes would be applied.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayFileName => FileNameHelper.ComputeOutputFileName(FilePath);
|
||||||
public string FileSizeDisplay { get; }
|
public string FileSizeDisplay { get; }
|
||||||
public ObservableCollection<TrackItem> Tracks { get; } = new();
|
public ObservableCollection<TrackItem> Tracks { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// External subtitle files (dropped separately and matched to this file by
|
||||||
|
/// episode — e.g. "S04E06", or just a bare episode number like "06") that
|
||||||
|
/// will be muxed in as additional subtitle tracks when this file is
|
||||||
|
/// processed.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
|
||||||
|
|
||||||
|
public bool HasExternalSubtitles => ExternalSubtitles.Count > 0;
|
||||||
|
|
||||||
|
public string ExternalSubtitlesTooltip => ExternalSubtitles.Count == 0
|
||||||
|
? string.Empty
|
||||||
|
: "External subtitles to mux in:\n" + string.Join("\n", ExternalSubtitles.Select(s => $"- {s.FileName} ({s.LanguageDisplay})"));
|
||||||
|
|
||||||
public string Status
|
public string Status
|
||||||
{
|
{
|
||||||
get => _status;
|
get => _status;
|
||||||
@@ -63,7 +85,12 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
|
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
|
||||||
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy);
|
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy);
|
||||||
|
|
||||||
return audioSelected == 1 && subtitleSelected == 1
|
// A subtitle requirement is met either by picking exactly one
|
||||||
|
// internal track, or by having at least one matched external
|
||||||
|
// subtitle file (which can also be combined with one internal track).
|
||||||
|
bool subtitleOk = subtitleSelected == 1 || (subtitleSelected == 0 && ExternalSubtitles.Count > 0);
|
||||||
|
|
||||||
|
return audioSelected == 1 && subtitleOk
|
||||||
? FileLoadState.Correct
|
? FileLoadState.Correct
|
||||||
: FileLoadState.Incorrect;
|
: FileLoadState.Incorrect;
|
||||||
}
|
}
|
||||||
@@ -79,6 +106,7 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
FilePath = filePath;
|
FilePath = filePath;
|
||||||
FileSizeDisplay = FormatFileSize(filePath);
|
FileSizeDisplay = FormatFileSize(filePath);
|
||||||
Tracks.CollectionChanged += OnTracksChanged;
|
Tracks.CollectionChanged += OnTracksChanged;
|
||||||
|
ExternalSubtitles.CollectionChanged += OnExternalSubtitlesChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatFileSize(string filePath)
|
private static string FormatFileSize(string filePath)
|
||||||
@@ -104,6 +132,13 @@ public class QueuedFileItem : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnExternalSubtitlesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasExternalSubtitles));
|
||||||
|
OnPropertyChanged(nameof(ExternalSubtitlesTooltip));
|
||||||
|
NotifyLoadState();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.NewItems != null)
|
if (e.NewItems != null)
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Futonizer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure static helpers for computing the final output filename from a source
|
||||||
|
/// file path, applying release-tag prefixing and show-name injection.
|
||||||
|
/// </summary>
|
||||||
|
public static class FileNameHelper
|
||||||
|
{
|
||||||
|
// Folder suffix release tag: "Show Name-TTGA" → tag = "TTGA"
|
||||||
|
// All-uppercase, 2-8 chars, at end of folder name.
|
||||||
|
private static readonly Regex FolderSuffixTagRegex =
|
||||||
|
new(@"-([A-Z][A-Z0-9]{1,7})$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Folder suffix bracket release tag: "Show Name ... x264 [i_c]" → tag = "i_c"
|
||||||
|
// A bracketed tag at the very end of the folder name (as opposed to
|
||||||
|
// PrefixTagRegex, which matches one at the start).
|
||||||
|
private static readonly Regex FolderSuffixBracketTagRegex =
|
||||||
|
new(@"\[([^\[\]]+)\]$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// File suffix release tag: "…HEVC-DemiHuman" → tag = "DemiHuman"
|
||||||
|
// Mixed case, 3-21 chars (no dots/spaces), at end of filename stem.
|
||||||
|
// Min 3 chars avoids false-positives like "-v2" or "-HD". The word
|
||||||
|
// immediately preceding the dash must itself be recognisable quality/
|
||||||
|
// source/codec metadata (checked by the caller via IsMetadataToken), so
|
||||||
|
// an ordinary hyphenated title word (e.g. "Scary-oke") is never mistaken
|
||||||
|
// for a "-GroupName" suffix.
|
||||||
|
private static readonly Regex FileSuffixTagRegex =
|
||||||
|
new(@"(?:^|[\s.])([A-Za-z0-9]+)-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// File suffix release tag directly after a bracketed metadata block:
|
||||||
|
// "...[HULU WEBDL-1080p][EAC3 5.1][h264]-NTb" → tag = "NTb"
|
||||||
|
// Common on naming-bot-style releases that group tech specs into their
|
||||||
|
// own brackets and tack the group on with a dash at the very end. The
|
||||||
|
// bracket immediately before the dash must itself be recognisable
|
||||||
|
// metadata (checked by the caller via IsMetadataBracket), mirroring
|
||||||
|
// FileSuffixTagRegex's guard for the bare-word case.
|
||||||
|
private static readonly Regex FileSuffixBracketDashTagRegex =
|
||||||
|
new(@"\[([^\[\]]+)\]-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Trailing parenthesised metadata block ending in the release group as its
|
||||||
|
// last word, e.g. "... (1080p AMZN WEB-DL x265 Celdra)" → group="Celdra".
|
||||||
|
// Common on Amazon/streaming rips that don't set the group off with a
|
||||||
|
// "-" or its own brackets.
|
||||||
|
private static readonly Regex TrailingParenGroupRegex =
|
||||||
|
new(@"\(([^()]*)\)$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Prefix release tag: "[KAA] Show Name" → tag = "KAA"
|
||||||
|
private static readonly Regex PrefixTagRegex =
|
||||||
|
new(@"^\[(.+?)\]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Episode-only filename (no tag prefix): starts with S01E01, S1E1, etc.
|
||||||
|
private static readonly Regex EpisodeOnlyRegex =
|
||||||
|
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Episode start after a tag prefix is stripped: plain number OR S01E01 style.
|
||||||
|
// e.g. "03 - Deus Vult ..." or "S01E01-Deep Blue"
|
||||||
|
private static readonly Regex EpisodeStartRegex =
|
||||||
|
new(@"^(\d+|S\d+E\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Individual metadata-token patterns used to find where the show name ends
|
||||||
|
// inside a folder name.
|
||||||
|
private static readonly Regex[] MetadataPatterns =
|
||||||
|
[
|
||||||
|
new(@"^S\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01
|
||||||
|
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled), // S01E01...
|
||||||
|
new(@"^E[Pp]?\d+$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // E01, EP01
|
||||||
|
new(@"^\d{3,4}[pP]$", RegexOptions.Compiled), // 1080p, 720p
|
||||||
|
new(@"^(4K|UHD|2160p)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // 4K, UHD
|
||||||
|
new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources
|
||||||
|
new(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
|
||||||
|
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs
|
||||||
|
new(@"^(FLAC|AAC|DTS(-HD)?|TrueHD|Atmos|E?AC3|DDP?|Opus|MP3)(\d(\.\d)?)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs (+ optional channel suffix, e.g. DDP5.1)
|
||||||
|
new(@"^\d\.\d$", RegexOptions.Compiled), // bare channel count, e.g. 5.1
|
||||||
|
new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
|
||||||
|
];
|
||||||
|
|
||||||
|
// Whole-phrase fansub/release descriptor patterns, checked against an
|
||||||
|
// entire bracket's contents rather than word-by-word. Catches common
|
||||||
|
// multi-word descriptors (e.g. "Multiple Subtitle", "Dual Audio") whose
|
||||||
|
// individual words wouldn't otherwise match any MetadataPatterns entry,
|
||||||
|
// so they'd be mistaken for a release group name.
|
||||||
|
private static readonly Regex[] DescriptorPhrasePatterns =
|
||||||
|
[
|
||||||
|
new(@"^Multi(ple)?[\s-]?Sub(s|title|titles)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||||
|
new(@"^(Dual|Multi)[\s-]?Audio$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||||
|
new(@"^(Hard|Soft)[\s-]?sub(s)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||||
|
new(@"^(Uncensored|Censored|Batch|Complete)$", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the final output filename for a given source file path, applying
|
||||||
|
/// (in order):
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Filename suffix tag extraction — a trailing <c>-GroupName</c> on
|
||||||
|
/// the filename stem, a group tacked on after a bracketed metadata
|
||||||
|
/// block (e.g. <c>[h264]-NTb</c>), or a release group tucked in as
|
||||||
|
/// the last word of a trailing <c>(...)</c> metadata block (e.g.
|
||||||
|
/// Amazon rips like <c>(1080p AMZN WEB-DL x265 Celdra)</c>), is
|
||||||
|
/// stripped and promoted to a bracket prefix. Takes priority over
|
||||||
|
/// any folder-derived tag.</item>
|
||||||
|
/// <item>Show-name injection — if the (now tag-free) filename starts with
|
||||||
|
/// an episode code (e.g. S01E01) the show name is derived from the
|
||||||
|
/// parent folder and prepended.</item>
|
||||||
|
/// <item>Release-tag prefix — a <c>[TAG]</c> bracket prefix, sourced from
|
||||||
|
/// the filename suffix, the folder suffix, or the folder prefix,
|
||||||
|
/// in that priority order.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Returns the original filename unchanged when no transformations apply.
|
||||||
|
/// </summary>
|
||||||
|
public static string ComputeOutputFileName(string sourceFilePath)
|
||||||
|
{
|
||||||
|
string fileName = Path.GetFileName(sourceFilePath);
|
||||||
|
string ext = Path.GetExtension(fileName);
|
||||||
|
string stemNoExt = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
string? folder = Path.GetDirectoryName(sourceFilePath);
|
||||||
|
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.
|
||||||
|
// e.g. "...x264-DemiHuman" → tag="DemiHuman", stem="...x264"
|
||||||
|
// Only fires when the word right before the dash is itself a
|
||||||
|
// recognised metadata token (codec/source/etc.), so ordinary
|
||||||
|
// hyphenated title words are left alone.
|
||||||
|
string? fileTag = null;
|
||||||
|
var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt);
|
||||||
|
var bracketDashMatch = FileSuffixBracketDashTagRegex.Match(stemNoExt);
|
||||||
|
if (fileSuffixMatch.Success
|
||||||
|
&& IsMetadataToken(fileSuffixMatch.Groups[1].Value)
|
||||||
|
&& !IsMetadataToken(fileSuffixMatch.Groups[2].Value))
|
||||||
|
{
|
||||||
|
fileTag = fileSuffixMatch.Groups[2].Value;
|
||||||
|
stemNoExt = stemNoExt[..(fileSuffixMatch.Groups[2].Index - 1)];
|
||||||
|
fileName = stemNoExt + ext;
|
||||||
|
}
|
||||||
|
else if (bracketDashMatch.Success
|
||||||
|
&& IsMetadataBracket(bracketDashMatch.Groups[1].Value)
|
||||||
|
&& !IsMetadataToken(bracketDashMatch.Groups[2].Value))
|
||||||
|
{
|
||||||
|
// 1a. Extract a release group tacked on after a bracketed metadata
|
||||||
|
// block, e.g. "...[h264]-NTb" → tag="NTb", stem="...[h264]".
|
||||||
|
fileTag = bracketDashMatch.Groups[2].Value;
|
||||||
|
stemNoExt = stemNoExt[..(bracketDashMatch.Groups[2].Index - 1)];
|
||||||
|
fileName = stemNoExt + ext;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 1b. Extract a release group tucked in as the last word of a
|
||||||
|
// trailing "(...)" metadata block, e.g.
|
||||||
|
// "... (1080p AMZN WEB-DL x265 Celdra)" → tag="Celdra",
|
||||||
|
// stem="... (1080p AMZN WEB-DL x265)".
|
||||||
|
var parenMatch = TrailingParenGroupRegex.Match(stemNoExt);
|
||||||
|
if (parenMatch.Success)
|
||||||
|
{
|
||||||
|
string parenContent = parenMatch.Groups[1].Value;
|
||||||
|
var words = parenContent.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
// Only treat the last word as a release group if the rest of the
|
||||||
|
// block is recognisable quality/source/codec metadata — this
|
||||||
|
// avoids misfiring on unrelated parentheticals like "(2020)" or
|
||||||
|
// "(Dual Audio)" that don't carry a group name.
|
||||||
|
if (words.Length >= 2 && !IsMetadataToken(words[^1]) && words[..^1].Any(IsMetadataToken))
|
||||||
|
{
|
||||||
|
fileTag = words[^1];
|
||||||
|
string remaining = string.Join(" ", words[..^1]);
|
||||||
|
string beforeParen = stemNoExt[..parenMatch.Index].TrimEnd();
|
||||||
|
stemNoExt = string.IsNullOrEmpty(remaining) ? beforeParen : $"{beforeParen} ({remaining})";
|
||||||
|
fileName = stemNoExt + ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Determine the release tag: filename suffix > folder tag.
|
||||||
|
string? tag = fileTag;
|
||||||
|
if (string.IsNullOrEmpty(tag) && !string.IsNullOrEmpty(folderName))
|
||||||
|
tag = ExtractReleaseTag(folderName);
|
||||||
|
|
||||||
|
// 3. Check for episode-only patterns and inject show name if needed.
|
||||||
|
if (!string.IsNullOrEmpty(folderName))
|
||||||
|
{
|
||||||
|
// Pattern A: untagged stem starts with S01E01 (e.g. "S01E01-Deep Blue.mkv")
|
||||||
|
if (EpisodeOnlyRegex.IsMatch(stemNoExt))
|
||||||
|
{
|
||||||
|
string showName = ExtractShowName(folderName);
|
||||||
|
if (!string.IsNullOrEmpty(showName))
|
||||||
|
fileName = showName + " " + fileName;
|
||||||
|
}
|
||||||
|
// Pattern B: file already carries [TAG] but episode number follows directly.
|
||||||
|
// e.g. "[WBDP] 03 - Deus Vult ..." → insert show name after the tag.
|
||||||
|
else if (!string.IsNullOrEmpty(tag))
|
||||||
|
{
|
||||||
|
string tagPfx = $"[{tag}] ";
|
||||||
|
if (stemNoExt.StartsWith(tagPfx, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string afterTag = stemNoExt[tagPfx.Length..];
|
||||||
|
if (EpisodeStartRegex.IsMatch(afterTag))
|
||||||
|
{
|
||||||
|
string showName = ExtractShowName(folderName);
|
||||||
|
if (!string.IsNullOrEmpty(showName))
|
||||||
|
{
|
||||||
|
// Strip the tag, inject show name; tag is re-prepended in step 4.
|
||||||
|
fileName = showName + " " + fileName[tagPfx.Length..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Prepend release tag if not already present. Skip when the file
|
||||||
|
// already carries its own (different) bracket prefix — never stack
|
||||||
|
// a second tag on top of an existing one.
|
||||||
|
if (!string.IsNullOrEmpty(tag))
|
||||||
|
{
|
||||||
|
bool differsFromOriginalTag = originalPrefixTag != null
|
||||||
|
&& !string.Equals(originalPrefixTag, tag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!differsFromOriginalTag)
|
||||||
|
{
|
||||||
|
string tagPrefix = $"[{tag}]";
|
||||||
|
if (!fileName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
fileName = tagPrefix + " " + fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the release tag string (without brackets) from a folder name,
|
||||||
|
/// checking (in priority order): a dash suffix (<c>-TAG</c>), a bracketed
|
||||||
|
/// suffix (<c>[tag]</c> at the end), then a bracketed prefix (<c>[tag]</c>
|
||||||
|
/// at the start). Returns <c>null</c> when none are present.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ExtractReleaseTag(string folderName)
|
||||||
|
{
|
||||||
|
var suffixMatch = FolderSuffixTagRegex.Match(folderName);
|
||||||
|
if (suffixMatch.Success && !IsMetadataToken(suffixMatch.Groups[1].Value))
|
||||||
|
return suffixMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var bracketSuffixMatch = FolderSuffixBracketTagRegex.Match(folderName);
|
||||||
|
if (bracketSuffixMatch.Success && !IsMetadataBracket(bracketSuffixMatch.Groups[1].Value))
|
||||||
|
return bracketSuffixMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var prefixMatch = PrefixTagRegex.Match(folderName);
|
||||||
|
if (prefixMatch.Success && !IsMetadataBracket(prefixMatch.Groups[1].Value))
|
||||||
|
return prefixMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the show name from a folder name by stripping any release tag
|
||||||
|
/// (prefix or suffix) and then:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>If the remainder contains brackets (e.g. <c>[BD][1080p-FLAC]</c>),
|
||||||
|
/// returns the text that precedes the first bracket.</item>
|
||||||
|
/// <item>Otherwise splits on spaces and collects words up to the first
|
||||||
|
/// recognised metadata token (season, quality, source, codec, etc.).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static string ExtractShowName(string folderName)
|
||||||
|
{
|
||||||
|
// Strip release tags first.
|
||||||
|
string name = FolderSuffixTagRegex.Replace(folderName, "").Trim();
|
||||||
|
name = FolderSuffixBracketTagRegex.Replace(name, "").Trim();
|
||||||
|
name = PrefixTagRegex.Replace(name, "").Trim();
|
||||||
|
|
||||||
|
// Nothing left, or what remains immediately starts with a bracket — no show name.
|
||||||
|
if (string.IsNullOrEmpty(name) || name[0] == '[') return string.Empty;
|
||||||
|
|
||||||
|
// Bracket-style metadata: "The Saga of Tanya the Evil [BD][1080p-FLAC][HEVC]"
|
||||||
|
int bracketIdx = name.IndexOf('[');
|
||||||
|
if (bracketIdx > 0)
|
||||||
|
return name[..bracketIdx].Trim();
|
||||||
|
|
||||||
|
// Space-separated metadata: "Grand Blue Dreaming S01 1080p BD Remux FLAC"
|
||||||
|
var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var showWords = new List<string>();
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
if (IsMetadataToken(word)) break;
|
||||||
|
showWords.Add(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" ", showWords).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMetadataToken(string token)
|
||||||
|
{
|
||||||
|
foreach (var pattern in MetadataPatterns)
|
||||||
|
if (pattern.IsMatch(token)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when every word inside a bracketed block is recognisable quality/
|
||||||
|
/// source/codec metadata (e.g. "[HEVC]", "[1080p-FLAC]"), meaning it's
|
||||||
|
/// just another metadata tag rather than an actual release group name
|
||||||
|
/// (e.g. "[i_c]").
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsMetadataBracket(string content)
|
||||||
|
{
|
||||||
|
string trimmed = content.Trim();
|
||||||
|
if (DescriptorPhrasePatterns.Any(p => p.IsMatch(trimmed))) return true;
|
||||||
|
|
||||||
|
var parts = trimmed.Split(new[] { ' ', '-' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return parts.Length > 0 && parts.All(IsMetadataToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-8
@@ -93,9 +93,12 @@ public class MkvService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
|
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
|
||||||
/// tracks are always pre-checked. Exactly one audio track is pre-checked:
|
/// tracks are always pre-checked. Exactly one audio track is always
|
||||||
/// among Japanese/Chinese/undefined-language tracks, a stereo (2-channel)
|
/// pre-checked: among Japanese/Chinese/undefined-language tracks, a
|
||||||
/// track is preferred, otherwise the first matching track in file order.
|
/// stereo (2-channel) track is preferred, otherwise the first matching
|
||||||
|
/// track in file order. If no such track exists (e.g. only English
|
||||||
|
/// audio), the very first audio track in the file is pre-checked instead,
|
||||||
|
/// so a file is never left with no audio track selected.
|
||||||
/// Subtitle tracks are never pre-checked — the user picks one via the
|
/// Subtitle tracks are never pre-checked — the user picks one via the
|
||||||
/// track table, and the choice propagates to other queued files.
|
/// track table, and the choice propagates to other queued files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -108,7 +111,8 @@ public class MkvService
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
|
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
|
||||||
?? eligibleAudio.FirstOrDefault();
|
?? eligibleAudio.FirstOrDefault()
|
||||||
|
?? audioTracks.FirstOrDefault();
|
||||||
|
|
||||||
var items = info.Tracks.Select(t =>
|
var items = info.Tracks.Select(t =>
|
||||||
{
|
{
|
||||||
@@ -258,12 +262,13 @@ public class MkvService
|
|||||||
IReadOnlyList<int> videoKeepIds,
|
IReadOnlyList<int> videoKeepIds,
|
||||||
IReadOnlyList<int> audioKeepIds,
|
IReadOnlyList<int> audioKeepIds,
|
||||||
IReadOnlyList<int> subtitleKeepIds,
|
IReadOnlyList<int> subtitleKeepIds,
|
||||||
|
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles = null,
|
||||||
IProgress<ProcessingProgress>? progress = null,
|
IProgress<ProcessingProgress>? progress = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return StripTracksInternalAsync(
|
return StripTracksInternalAsync(
|
||||||
filePath, Path.GetFileName(filePath), outputFilePath,
|
filePath, Path.GetFileName(filePath), outputFilePath,
|
||||||
videoKeepIds, audioKeepIds, subtitleKeepIds,
|
videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
|
||||||
progress, ct);
|
progress, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +286,7 @@ public class MkvService
|
|||||||
null, // null = keep all video
|
null, // null = keep all video
|
||||||
scan.AudioKeepIds,
|
scan.AudioKeepIds,
|
||||||
scan.SubtitleKeepIds,
|
scan.SubtitleKeepIds,
|
||||||
|
null,
|
||||||
progress, ct);
|
progress, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +392,7 @@ public class MkvService
|
|||||||
IReadOnlyList<int>? videoKeepIds,
|
IReadOnlyList<int>? videoKeepIds,
|
||||||
IReadOnlyList<int>? audioKeepIds,
|
IReadOnlyList<int>? audioKeepIds,
|
||||||
IReadOnlyList<int>? subtitleKeepIds,
|
IReadOnlyList<int>? subtitleKeepIds,
|
||||||
|
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles,
|
||||||
IProgress<ProcessingProgress>? progress,
|
IProgress<ProcessingProgress>? progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -422,16 +429,52 @@ public class MkvService
|
|||||||
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
|
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
|
||||||
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
|
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
|
||||||
|
|
||||||
// The one subtitle track the user chose to keep is always flagged
|
// Only one subtitle track in the whole output should ever be
|
||||||
// as the default subtitle track in the output.
|
// flagged default. If any external subtitle is marked default,
|
||||||
|
// it takes priority over the kept internal subtitle track.
|
||||||
|
bool hasExternalDefault = externalSubtitles != null && externalSubtitles.Any(s => s.IsDefault);
|
||||||
|
|
||||||
if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
|
if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
|
||||||
{
|
{
|
||||||
psi.ArgumentList.Add("--default-track-flag");
|
psi.ArgumentList.Add("--default-track-flag");
|
||||||
psi.ArgumentList.Add($"{subtitleKeepIds[0]}:yes");
|
psi.ArgumentList.Add(hasExternalDefault ? $"{subtitleKeepIds[0]}:no" : $"{subtitleKeepIds[0]}:yes");
|
||||||
}
|
}
|
||||||
|
|
||||||
psi.ArgumentList.Add(filePath);
|
psi.ArgumentList.Add(filePath);
|
||||||
|
|
||||||
|
// Append each matched external subtitle file as its own input,
|
||||||
|
// with per-file options (language/name/flags) scoped to it.
|
||||||
|
if (externalSubtitles != null)
|
||||||
|
{
|
||||||
|
foreach (var sub in externalSubtitles)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(sub.Language) && !string.Equals(sub.Language, "und", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add("--language");
|
||||||
|
psi.ArgumentList.Add($"0:{sub.Language}");
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(sub.TrackName))
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add("--track-name");
|
||||||
|
psi.ArgumentList.Add($"0:{sub.TrackName}");
|
||||||
|
}
|
||||||
|
if (sub.Forced)
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add("--forced-display-flag");
|
||||||
|
psi.ArgumentList.Add("0:yes");
|
||||||
|
}
|
||||||
|
if (sub.HearingImpaired)
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add("--hearing-impaired-flag");
|
||||||
|
psi.ArgumentList.Add("0:yes");
|
||||||
|
}
|
||||||
|
psi.ArgumentList.Add("--default-track-flag");
|
||||||
|
psi.ArgumentList.Add(sub.IsDefault ? "0:yes" : "0:no");
|
||||||
|
|
||||||
|
psi.ArgumentList.Add(sub.FilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var process = new Process { StartInfo = psi };
|
using var process = new Process { StartInfo = psi };
|
||||||
process.Start();
|
process.Start();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Futonizer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of scanning an external subtitle's filename for language and
|
||||||
|
/// display hints.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct SubtitleLanguageInfo(string Language, bool Forced, bool HearingImpaired, string TrackName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort language/flag detection for external subtitle files, based on
|
||||||
|
/// common filename tokens (e.g. "Show.S04E06.eng.srt", "Show.S04E06.forced.srt").
|
||||||
|
/// Never throws — falls back to an "und" (undetermined) language with no
|
||||||
|
/// track name when nothing recognisable is found.
|
||||||
|
/// </summary>
|
||||||
|
public static class SubtitleLanguageHelper
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, string> TokenToIso6392 = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["en"] = "eng", ["eng"] = "eng", ["english"] = "eng",
|
||||||
|
["ja"] = "jpn", ["jp"] = "jpn", ["jpn"] = "jpn", ["japanese"] = "jpn",
|
||||||
|
["es"] = "spa", ["spa"] = "spa", ["spanish"] = "spa", ["esp"] = "spa",
|
||||||
|
["fr"] = "fre", ["fre"] = "fre", ["fra"] = "fre", ["french"] = "fre",
|
||||||
|
["de"] = "ger", ["ger"] = "ger", ["deu"] = "ger", ["german"] = "ger",
|
||||||
|
["it"] = "ita", ["ita"] = "ita", ["italian"] = "ita",
|
||||||
|
["pt"] = "por", ["por"] = "por", ["portuguese"] = "por", ["ptbr"] = "por",
|
||||||
|
["ru"] = "rus", ["rus"] = "rus", ["russian"] = "rus",
|
||||||
|
["zh"] = "chi", ["chi"] = "chi", ["zho"] = "chi", ["chinese"] = "chi", ["cmn"] = "chi", ["yue"] = "chi",
|
||||||
|
["ko"] = "kor", ["kor"] = "kor", ["korean"] = "kor",
|
||||||
|
["ar"] = "ara", ["ara"] = "ara", ["arabic"] = "ara",
|
||||||
|
["nl"] = "dut", ["dut"] = "dut", ["nld"] = "dut", ["dutch"] = "dut",
|
||||||
|
["sv"] = "swe", ["swe"] = "swe", ["swedish"] = "swe",
|
||||||
|
["no"] = "nor", ["nor"] = "nor", ["norwegian"] = "nor",
|
||||||
|
["da"] = "dan", ["dan"] = "dan", ["danish"] = "dan",
|
||||||
|
["fi"] = "fin", ["fin"] = "fin", ["finnish"] = "fin",
|
||||||
|
["pl"] = "pol", ["pol"] = "pol", ["polish"] = "pol",
|
||||||
|
["tr"] = "tur", ["tur"] = "tur", ["turkish"] = "tur",
|
||||||
|
["vi"] = "vie", ["vie"] = "vie", ["vietnamese"] = "vie",
|
||||||
|
["th"] = "tha", ["tha"] = "tha", ["thai"] = "tha",
|
||||||
|
["id"] = "ind", ["ind"] = "ind", ["indonesian"] = "ind",
|
||||||
|
["he"] = "heb", ["heb"] = "heb", ["hebrew"] = "heb",
|
||||||
|
["hin"] = "hin", ["hindi"] = "hin",
|
||||||
|
["cs"] = "cze", ["cze"] = "cze", ["ces"] = "cze", ["czech"] = "cze",
|
||||||
|
["el"] = "gre", ["gre"] = "gre", ["ell"] = "gre", ["greek"] = "gre",
|
||||||
|
["hu"] = "hun", ["hun"] = "hun", ["hungarian"] = "hun",
|
||||||
|
["ro"] = "rum", ["rum"] = "rum", ["ron"] = "rum", ["romanian"] = "rum",
|
||||||
|
["uk"] = "ukr", ["ukr"] = "ukr", ["ukrainian"] = "ukr",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Iso6392ToDisplayName = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["eng"] = "English", ["jpn"] = "Japanese", ["spa"] = "Spanish", ["fre"] = "French",
|
||||||
|
["ger"] = "German", ["ita"] = "Italian", ["por"] = "Portuguese", ["rus"] = "Russian",
|
||||||
|
["chi"] = "Chinese", ["kor"] = "Korean", ["ara"] = "Arabic", ["dut"] = "Dutch",
|
||||||
|
["swe"] = "Swedish", ["nor"] = "Norwegian", ["dan"] = "Danish", ["fin"] = "Finnish",
|
||||||
|
["pol"] = "Polish", ["tur"] = "Turkish", ["vie"] = "Vietnamese", ["tha"] = "Thai",
|
||||||
|
["ind"] = "Indonesian", ["heb"] = "Hebrew", ["hin"] = "Hindi", ["cze"] = "Czech",
|
||||||
|
["gre"] = "Greek", ["hun"] = "Hungarian", ["rum"] = "Romanian", ["ukr"] = "Ukrainian",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly char[] TokenSeparators = { '.', '_', '-', ' ', '(', ')', '[', ']' };
|
||||||
|
|
||||||
|
public static SubtitleLanguageInfo Detect(string fileName)
|
||||||
|
{
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
var tokens = stem.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
string language = "und";
|
||||||
|
bool forced = false;
|
||||||
|
bool hearingImpaired = false;
|
||||||
|
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
if (string.Equals(token, "forced", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
forced = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (string.Equals(token, "sdh", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(token, "cc", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
hearingImpaired = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (language == "und" && TokenToIso6392.TryGetValue(token, out var iso))
|
||||||
|
language = iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
string trackName = string.Empty;
|
||||||
|
if (Iso6392ToDisplayName.TryGetValue(language, out var displayName))
|
||||||
|
trackName = displayName;
|
||||||
|
|
||||||
|
if (forced || hearingImpaired)
|
||||||
|
{
|
||||||
|
var suffixes = new List<string>();
|
||||||
|
if (forced) suffixes.Add("Forced");
|
||||||
|
if (hearingImpaired) suffixes.Add("SDH");
|
||||||
|
string suffix = $" ({string.Join(", ", suffixes)})";
|
||||||
|
trackName = string.IsNullOrEmpty(trackName) ? suffix.Trim(' ', '(', ')') : trackName + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SubtitleLanguageInfo(language, forced, hearingImpaired, trackName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Futonizer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recognises subtitle files by extension and extracts episode information
|
||||||
|
/// from a filename — either a normalized season+episode code (e.g.
|
||||||
|
/// "S04E06") or, failing that, a bare episode number (e.g. "06", "6",
|
||||||
|
/// "16") — used to match dropped subtitle files to the video file for the
|
||||||
|
/// same episode.
|
||||||
|
/// </summary>
|
||||||
|
public static class SubtitleMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Every subtitle container/format extension mkvmerge (and therefore
|
||||||
|
/// Futonizer) can mux in: SubRip, (Advanced) SubStation Alpha, VobSub,
|
||||||
|
/// WebVTT, HDMV/PGS, Universal Subtitle Format, SAMI, TTML/DFXP, EBU-STL,
|
||||||
|
/// MicroDVD/SubViewer (both commonly saved with a plain ".sub" extension),
|
||||||
|
/// and Kate.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] SupportedExtensions =
|
||||||
|
{
|
||||||
|
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", ".webvtt",
|
||||||
|
".sup", ".pgs", ".usf", ".smi", ".sami", ".ttml", ".dfxp",
|
||||||
|
".stl", ".kate",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsSubtitleFile(string path)
|
||||||
|
{
|
||||||
|
string ext = Path.GetExtension(path);
|
||||||
|
return !string.IsNullOrEmpty(ext)
|
||||||
|
&& SupportedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "S04E06", "S4E6", "S04 E06", "S04-E06", "S04.E06" — the standard scene/anime tagging scheme.
|
||||||
|
private static readonly Regex SeasonEpisodeRegex =
|
||||||
|
new(@"S(\d{1,4})[\s._-]*E(\d{1,4})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Fallback: "4x06" style season x episode separator.
|
||||||
|
private static readonly Regex SeasonXEpisodeRegex =
|
||||||
|
new(@"(?<![\d.])(\d{1,2})x(\d{2,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Explicit episode marker with no season prefix: "E06", "EP06", "Episode 06".
|
||||||
|
// Requires a non-alphanumeric character (or start of string) right before
|
||||||
|
// the marker so it doesn't fire on the "e" inside an unrelated word.
|
||||||
|
private static readonly Regex ExplicitEpisodeMarkerRegex =
|
||||||
|
new(@"(?:^|[^A-Za-z0-9])(?:episode|ep|e)[\s._-]*(\d{1,3})(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// Whole filename stem is just a number, e.g. "06", "6", "16".
|
||||||
|
private static readonly Regex BareWholeNumberRegex =
|
||||||
|
new(@"^\d{1,4}$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// A number cleanly delimited at the very start of the stem by a
|
||||||
|
// separator, e.g. "06 - Title", "06_Title", "06.Title".
|
||||||
|
private static readonly Regex BareLeadingNumberRegex =
|
||||||
|
new(@"^(\d{1,3})(?=[\s._-])", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// A number delimited by a " - " (or "-") release-style dash separator
|
||||||
|
// that isn't at the very end of the stem, e.g. "Show - 06 - Title",
|
||||||
|
// "[Group] Show Name - 06 [1080p]". Requires whitespace immediately
|
||||||
|
// before the dash so it doesn't fire on glued tokens like "x264-06".
|
||||||
|
private static readonly Regex DashDelimitedNumberRegex =
|
||||||
|
new(@"(?<=\s-\s)(\d{1,3})(?!\d)|(?<=\s-)(\d{1,3})(?!\d)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// A number cleanly delimited at the very end of the stem by a
|
||||||
|
// separator, e.g. "Show - 06", "Show_06", "Show.06". A "." only counts
|
||||||
|
// as a separator here when it isn't itself preceded by a digit, so
|
||||||
|
// decimal-looking tokens like audio channel counts ("DD5.1") aren't
|
||||||
|
// mistaken for an episode number.
|
||||||
|
private static readonly Regex BareTrailingNumberRegex =
|
||||||
|
new(@"(?<=[\s_-]|(?<!\d)\.)(\d{1,3})$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts and normalizes the episode code from a filename (e.g.
|
||||||
|
/// "Show.S4E6.1080p.mkv" and "Show - 04x06 - Title.srt" both return
|
||||||
|
/// "S04E06"), so files can be matched regardless of zero-padding or
|
||||||
|
/// separator style. Returns null if no season+episode code is found —
|
||||||
|
/// use <see cref="ExtractEpisodeNumber"/> for the season-agnostic
|
||||||
|
/// fallback (plain episode numbers like "06").
|
||||||
|
/// </summary>
|
||||||
|
public static string? ExtractEpisodeCode(string fileName)
|
||||||
|
{
|
||||||
|
var match = SeasonEpisodeRegex.Match(fileName);
|
||||||
|
if (!match.Success)
|
||||||
|
match = SeasonXEpisodeRegex.Match(fileName);
|
||||||
|
|
||||||
|
if (!match.Success) return null;
|
||||||
|
|
||||||
|
if (!int.TryParse(match.Groups[1].Value, out int season)) return null;
|
||||||
|
if (!int.TryParse(match.Groups[2].Value, out int episode)) return null;
|
||||||
|
|
||||||
|
return $"S{season:D2}E{episode:D2}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts just the episode number from a filename, ignoring season.
|
||||||
|
/// Tries, in order: a full season+episode code (e.g. "S04E06" → 6), an
|
||||||
|
/// explicit marker with no season ("E06", "EP06", "Episode 06" → 6), or
|
||||||
|
/// a bare number that is either the entire filename stem ("06", "6",
|
||||||
|
/// "16") or cleanly delimited from the rest of the name by a separator
|
||||||
|
/// ("06 - Title", "Show - 06"). Bare numbers are capped at a few digits
|
||||||
|
/// so resolutions (1080p), years, and bitrates aren't mistaken for an
|
||||||
|
/// episode number. Returns null if nothing usable is found.
|
||||||
|
/// </summary>
|
||||||
|
public static int? ExtractEpisodeNumber(string fileName)
|
||||||
|
{
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
|
||||||
|
var seasonMatch = SeasonEpisodeRegex.Match(stem);
|
||||||
|
if (!seasonMatch.Success)
|
||||||
|
seasonMatch = SeasonXEpisodeRegex.Match(stem);
|
||||||
|
if (seasonMatch.Success && int.TryParse(seasonMatch.Groups[2].Value, out int fromSeasonCode))
|
||||||
|
return fromSeasonCode;
|
||||||
|
|
||||||
|
var markerMatch = ExplicitEpisodeMarkerRegex.Match(stem);
|
||||||
|
if (markerMatch.Success && int.TryParse(markerMatch.Groups[1].Value, out int fromMarker))
|
||||||
|
return fromMarker;
|
||||||
|
|
||||||
|
if (BareWholeNumberRegex.IsMatch(stem) && int.TryParse(stem, out int wholeStem))
|
||||||
|
return wholeStem;
|
||||||
|
|
||||||
|
var leadingMatch = BareLeadingNumberRegex.Match(stem);
|
||||||
|
if (leadingMatch.Success && int.TryParse(leadingMatch.Groups[1].Value, out int fromLeading))
|
||||||
|
return fromLeading;
|
||||||
|
|
||||||
|
var dashMatch = DashDelimitedNumberRegex.Match(stem);
|
||||||
|
if (dashMatch.Success)
|
||||||
|
{
|
||||||
|
string dashValue = dashMatch.Groups[1].Success ? dashMatch.Groups[1].Value : dashMatch.Groups[2].Value;
|
||||||
|
if (int.TryParse(dashValue, out int fromDash))
|
||||||
|
return fromDash;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trailingMatch = BareTrailingNumberRegex.Match(stem);
|
||||||
|
if (trailingMatch.Success && int.TryParse(trailingMatch.Groups[1].Value, out int fromTrailing))
|
||||||
|
return fromTrailing;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,7 +144,8 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
{
|
{
|
||||||
await semaphore.WaitAsync(ct);
|
await semaphore.WaitAsync(ct);
|
||||||
|
|
||||||
var job = new JobProgressItem { FileName = file.FileName, FileSizeBytes = SafeFileLength(file.FilePath) };
|
string computedFileName = FileNameHelper.ComputeOutputFileName(file.FilePath);
|
||||||
|
var job = new JobProgressItem { FileName = computedFileName, FileSizeBytes = SafeFileLength(file.FilePath) };
|
||||||
job.PropertyChanged += (_, args) =>
|
job.PropertyChanged += (_, args) =>
|
||||||
{
|
{
|
||||||
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
|
if (args.PropertyName == nameof(JobProgressItem.MegabytesPerSecond))
|
||||||
@@ -157,9 +158,11 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
string sourceDir = Path.GetDirectoryName(file.FilePath) ?? "";
|
||||||
|
|
||||||
string outputFilePath = overwritingInPlace
|
string outputFilePath = overwritingInPlace
|
||||||
? file.FilePath
|
? Path.Combine(sourceDir, computedFileName)
|
||||||
: Path.Combine(_outputFolder, file.FileName);
|
: Path.Combine(_outputFolder, computedFileName);
|
||||||
|
|
||||||
var videoIds = file.Tracks
|
var videoIds = file.Tracks
|
||||||
.Where(t => t.Type == "video" && t.Copy)
|
.Where(t => t.Type == "video" && t.Copy)
|
||||||
@@ -171,7 +174,7 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
.Where(t => t.Type == "subtitles" && t.Copy)
|
.Where(t => t.Type == "subtitles" && t.Copy)
|
||||||
.Select(t => t.Id).ToList();
|
.Select(t => t.Id).ToList();
|
||||||
|
|
||||||
bool nothingToStrip = file.Tracks.All(t => t.Copy);
|
bool nothingToStrip = file.Tracks.All(t => t.Copy) && file.ExternalSubtitles.Count == 0;
|
||||||
|
|
||||||
var progress = new Progress<ProcessingProgress>(p =>
|
var progress = new Progress<ProcessingProgress>(p =>
|
||||||
{
|
{
|
||||||
@@ -184,10 +187,14 @@ public partial class ProgressWindow : FluentWindow
|
|||||||
: await service.StripTracksAsync(
|
: await service.StripTracksAsync(
|
||||||
file.FilePath, outputFilePath,
|
file.FilePath, outputFilePath,
|
||||||
videoIds, audioIds, subIds,
|
videoIds, audioIds, subIds,
|
||||||
|
file.ExternalSubtitles.ToList(),
|
||||||
progress, ct);
|
progress, ct);
|
||||||
|
|
||||||
string tag = result.Success ? "[DONE]" : "[FAIL]";
|
string tag = result.Success ? "[DONE]" : "[FAIL]";
|
||||||
AppendLog($"{tag} {result.FileName} — {result.Message}");
|
string extInfo = file.ExternalSubtitles.Count > 0
|
||||||
|
? $" (+{file.ExternalSubtitles.Count} external sub{(file.ExternalSubtitles.Count == 1 ? "" : "s")})"
|
||||||
|
: string.Empty;
|
||||||
|
AppendLog($"{tag} {Path.GetFileName(result.OutputFilePath)}{extInfo} — {result.Message}");
|
||||||
|
|
||||||
HistoryService.Add(new HistoryEntry
|
HistoryService.Add(new HistoryEntry
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user