Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3efe246a20 | ||
|
|
10d7c14444 | ||
|
|
c7c125d40b | ||
|
|
304d9ad047 | ||
|
|
11a519d309 | ||
|
|
8b94fefe0f |
@@ -1,5 +1,6 @@
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
*.user
|
||||
history.json
|
||||
settings.json
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>Futonizer</AssemblyName>
|
||||
<RootNamespace>Futonizer</RootNamespace>
|
||||
<Version>1.0.3</Version>
|
||||
<FileVersion>1.0.3.0</FileVersion>
|
||||
<Version>1.0.8</Version>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
+63
-3
@@ -137,7 +137,7 @@
|
||||
<Grid Grid.Row="1">
|
||||
<TextBlock x:Name="DropHint"
|
||||
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"
|
||||
IsHitTestVisible="False"/>
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
<ColumnDefinition Width="16"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="18"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
@@ -179,12 +180,21 @@
|
||||
FontSize="12" VerticalAlignment="Center" Margin="6,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="{Binding FileSizeDisplay}"
|
||||
<Border Grid.Column="2" CornerRadius="8" Padding="5,1" Margin="2,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource AccentFillColorSecondaryBrush}"
|
||||
Visibility="{Binding HasExternalSubtitles, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="{Binding ExternalSubtitlesTooltip}">
|
||||
<TextBlock Text="{Binding ExternalSubtitles.Count, StringFormat={}+{0} sub}"
|
||||
FontSize="9" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="3" Text="{Binding FileSizeDisplay}"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="4,0"
|
||||
Opacity="0.6"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||
|
||||
<Button Grid.Column="3" Content="×"
|
||||
<Button Grid.Column="4" Content="×"
|
||||
Tag="{Binding}"
|
||||
Click="RemoveFile_Click"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
@@ -209,6 +219,55 @@
|
||||
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- External subtitles matched to the selected file -->
|
||||
<Border x:Name="ExternalSubsPanel" Grid.Row="0" Margin="10,8,10,0"
|
||||
Padding="8,6" CornerRadius="6" Visibility="Collapsed"
|
||||
Background="{DynamicResource ControlFillColorSecondaryBrush}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="External subtitles to mux in" FontSize="11"
|
||||
FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
|
||||
<ItemsControl x:Name="ExternalSubsList">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
|
||||
BorderBrush="{DynamicResource ControlElevationBorderBrush}"
|
||||
BorderThickness="1" CornerRadius="4"
|
||||
Margin="0,0,6,6" Padding="6,3">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding LanguageDisplay}" FontSize="11"
|
||||
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding FileName}" FontSize="11" Opacity="0.6"
|
||||
Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
MaxWidth="220" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="DEFAULT" FontSize="9" FontWeight="Bold"
|
||||
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
|
||||
Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsDefault, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="×" Tag="{Binding}"
|
||||
Click="RemoveExternalSubtitle_Click"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Padding="6,0,0,0" Cursor="Hand" FontSize="13"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<TextBlock x:Name="NoSelectionHint"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Text="Select a file to view its tracks"
|
||||
@@ -328,6 +387,7 @@
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
+146
-9
@@ -20,6 +20,7 @@ public partial class MainWindow : FluentWindow
|
||||
|
||||
private AppSettings _settings = new();
|
||||
private readonly ObservableCollection<QueuedFileItem> _fileQueue = new();
|
||||
private readonly List<string> _pendingSubtitles = new();
|
||||
private readonly SemaphoreSlim _scanSemaphore = new(MaxConcurrentScans);
|
||||
private bool _isProcessing;
|
||||
private bool _propagatingSelection;
|
||||
@@ -196,10 +197,9 @@ public partial class MainWindow : FluentWindow
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||
{
|
||||
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||
bool hasMkv = paths.Any(p =>
|
||||
string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
|
||||
&& File.Exists(p));
|
||||
e.Effects = hasMkv ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
bool hasAcceptedFile = paths.Any(p =>
|
||||
File.Exists(p) && (IsMkvFile(p) || SubtitleMatcher.IsSubtitleFile(p)));
|
||||
e.Effects = hasAcceptedFile ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -208,6 +208,9 @@ public partial class MainWindow : FluentWindow
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static bool IsMkvFile(string path)
|
||||
=> string.Equals(Path.GetExtension(path), ".mkv", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private void Window_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
||||
@@ -215,28 +218,158 @@ public partial class MainWindow : FluentWindow
|
||||
Activate();
|
||||
|
||||
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||
var newFiles = paths
|
||||
.Where(p => string.Equals(Path.GetExtension(p), ".mkv", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
var newVideoPaths = paths
|
||||
.Where(p => IsMkvFile(p)
|
||||
&& File.Exists(p)
|
||||
&& !_fileQueue.Any(q => string.Equals(q.FilePath, p, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
foreach (var path in newFiles)
|
||||
var subtitlePaths = paths
|
||||
.Where(p => SubtitleMatcher.IsSubtitleFile(p) && File.Exists(p))
|
||||
.ToList();
|
||||
|
||||
var newItems = new List<QueuedFileItem>();
|
||||
foreach (var path in newVideoPaths)
|
||||
{
|
||||
var item = new QueuedFileItem(path);
|
||||
item.PropertyChanged += QueuedFileItem_PropertyChanged;
|
||||
_fileQueue.Add(item);
|
||||
newItems.Add(item);
|
||||
_ = ScanFileItemAsync(item);
|
||||
}
|
||||
|
||||
// Newly-added videos might satisfy subtitles dropped in earlier (or
|
||||
// in this same batch, if the subtitle sorts before its video).
|
||||
foreach (var item in newItems)
|
||||
MatchPendingSubtitlesToFile(item);
|
||||
|
||||
int matchedSubtitles = 0;
|
||||
foreach (var subPath in subtitlePaths)
|
||||
{
|
||||
if (MatchSubtitleToQueue(subPath))
|
||||
matchedSubtitles++;
|
||||
else if (!_pendingSubtitles.Contains(subPath, StringComparer.OrdinalIgnoreCase))
|
||||
_pendingSubtitles.Add(subPath);
|
||||
}
|
||||
|
||||
UpdateLoadingOverlay();
|
||||
|
||||
if (newFiles.Count > 0)
|
||||
if (newItems.Count > 0)
|
||||
{
|
||||
var first = _fileQueue.FirstOrDefault(q =>
|
||||
string.Equals(q.FilePath, newFiles[0], StringComparison.OrdinalIgnoreCase));
|
||||
string.Equals(q.FilePath, newVideoPaths[0], StringComparison.OrdinalIgnoreCase));
|
||||
if (first != null) FileQueueList.SelectedItem = first;
|
||||
}
|
||||
|
||||
int unmatchedSubtitles = subtitlePaths.Count - matchedSubtitles;
|
||||
if (matchedSubtitles > 0 || unmatchedSubtitles > 0)
|
||||
{
|
||||
StatusText.Text = unmatchedSubtitles > 0
|
||||
? $"Matched {matchedSubtitles} subtitle(s); {unmatchedSubtitles} couldn't be matched to an episode code (S04E06 style)."
|
||||
: $"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 code (e.g. S04E06). Falls back to the single
|
||||
/// queued file when there's exactly one and no episode code 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? code = SubtitleMatcher.ExtractEpisodeCode(subFileName);
|
||||
|
||||
List<QueuedFileItem> targets;
|
||||
if (code != null)
|
||||
{
|
||||
targets = _fileQueue
|
||||
.Where(q => string.Equals(SubtitleMatcher.ExtractEpisodeCode(q.FileName), code, StringComparison.OrdinalIgnoreCase))
|
||||
.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 sharing this video's
|
||||
/// episode code, and attaches + removes any matches from the pending list.
|
||||
/// </summary>
|
||||
private void MatchPendingSubtitlesToFile(QueuedFileItem item)
|
||||
{
|
||||
if (_pendingSubtitles.Count == 0) return;
|
||||
|
||||
string? code = SubtitleMatcher.ExtractEpisodeCode(item.FileName);
|
||||
if (code == null) return;
|
||||
|
||||
var matches = _pendingSubtitles
|
||||
.Where(p => string.Equals(SubtitleMatcher.ExtractEpisodeCode(Path.GetFileName(p)), code, StringComparison.OrdinalIgnoreCase))
|
||||
.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 ─────────────────────────────────────────────────────────
|
||||
@@ -457,12 +590,16 @@ public partial class MainWindow : FluentWindow
|
||||
TrackGrid.ItemsSource = file.Tracks;
|
||||
TrackGrid.Visibility = Visibility.Visible;
|
||||
NoSelectionHint.Visibility = Visibility.Collapsed;
|
||||
ExternalSubsList.ItemsSource = file.ExternalSubtitles;
|
||||
ExternalSubsPanel.Visibility = file.HasExternalSubtitles ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
TrackGrid.ItemsSource = null;
|
||||
TrackGrid.Visibility = Visibility.Collapsed;
|
||||
NoSelectionHint.Visibility = Visibility.Visible;
|
||||
ExternalSubsList.ItemsSource = null;
|
||||
ExternalSubsPanel.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 code (e.g. S04E06),
|
||||
/// 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));
|
||||
}
|
||||
@@ -43,6 +43,19 @@ public class QueuedFileItem : INotifyPropertyChanged
|
||||
public string FileSizeDisplay { get; }
|
||||
public ObservableCollection<TrackItem> Tracks { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// External subtitle files (dropped separately and matched to this file by
|
||||
/// episode code, e.g. S04E06) that will be muxed in as additional
|
||||
/// subtitle tracks when this file is processed.
|
||||
/// </summary>
|
||||
public ObservableCollection<ExternalSubtitleTrack> ExternalSubtitles { get; } = new();
|
||||
|
||||
public bool HasExternalSubtitles => ExternalSubtitles.Count > 0;
|
||||
|
||||
public string ExternalSubtitlesTooltip => ExternalSubtitles.Count == 0
|
||||
? string.Empty
|
||||
: "External subtitles to mux in:\n" + string.Join("\n", ExternalSubtitles.Select(s => $"- {s.FileName} ({s.LanguageDisplay})"));
|
||||
|
||||
public string Status
|
||||
{
|
||||
get => _status;
|
||||
@@ -71,7 +84,12 @@ public class QueuedFileItem : INotifyPropertyChanged
|
||||
int audioSelected = Tracks.Count(t => t.Type == "audio" && t.Copy);
|
||||
int subtitleSelected = Tracks.Count(t => t.Type == "subtitles" && t.Copy);
|
||||
|
||||
return audioSelected == 1 && subtitleSelected == 1
|
||||
// A subtitle requirement is met either by picking exactly one
|
||||
// internal track, or by having at least one matched external
|
||||
// subtitle file (which can also be combined with one internal track).
|
||||
bool subtitleOk = subtitleSelected == 1 || (subtitleSelected == 0 && ExternalSubtitles.Count > 0);
|
||||
|
||||
return audioSelected == 1 && subtitleOk
|
||||
? FileLoadState.Correct
|
||||
: FileLoadState.Incorrect;
|
||||
}
|
||||
@@ -87,6 +105,7 @@ public class QueuedFileItem : INotifyPropertyChanged
|
||||
FilePath = filePath;
|
||||
FileSizeDisplay = FormatFileSize(filePath);
|
||||
Tracks.CollectionChanged += OnTracksChanged;
|
||||
ExternalSubtitles.CollectionChanged += OnExternalSubtitlesChanged;
|
||||
}
|
||||
|
||||
private static string FormatFileSize(string filePath)
|
||||
@@ -112,6 +131,13 @@ public class QueuedFileItem : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExternalSubtitlesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasExternalSubtitles));
|
||||
OnPropertyChanged(nameof(ExternalSubtitlesTooltip));
|
||||
NotifyLoadState();
|
||||
}
|
||||
|
||||
private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.NewItems != null)
|
||||
|
||||
+129
-32
@@ -9,19 +9,37 @@ namespace Futonizer.Services;
|
||||
/// </summary>
|
||||
public static class FileNameHelper
|
||||
{
|
||||
// Suffix release tag: "Show Name-TTGA" → tag = "TTGA"
|
||||
// Must be 2-8 uppercase letters/digits at the very end, preceded by a dash.
|
||||
private static readonly Regex SuffixTagRegex =
|
||||
// Folder suffix release tag: "Show Name-TTGA" → tag = "TTGA"
|
||||
// All-uppercase, 2-8 chars, at end of folder name.
|
||||
private static readonly Regex FolderSuffixTagRegex =
|
||||
new(@"-([A-Z][A-Z0-9]{1,7})$", RegexOptions.Compiled);
|
||||
|
||||
// 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".
|
||||
private static readonly Regex FileSuffixTagRegex =
|
||||
new(@"-([A-Za-z][A-Za-z0-9]{2,20})$", RegexOptions.Compiled);
|
||||
|
||||
// Trailing parenthesised metadata block ending in the release group as its
|
||||
// last word, e.g. "... (1080p AMZN WEB-DL x265 Celdra)" → group="Celdra".
|
||||
// Common on Amazon/streaming rips that don't set the group off with a
|
||||
// "-" or its own brackets.
|
||||
private static readonly Regex TrailingParenGroupRegex =
|
||||
new(@"\(([^()]*)\)$", RegexOptions.Compiled);
|
||||
|
||||
// Prefix release tag: "[KAA] Show Name" → tag = "KAA"
|
||||
private static readonly Regex PrefixTagRegex =
|
||||
new(@"^\[(.+?)\]", RegexOptions.Compiled);
|
||||
|
||||
// Episode-only filename: starts with S01E01, S1E1, etc. (no show name present)
|
||||
// Episode-only filename (no tag prefix): starts with S01E01, S1E1, etc.
|
||||
private static readonly Regex EpisodeOnlyRegex =
|
||||
new(@"^S\d+E\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Episode start after a tag prefix is stripped: plain number OR S01E01 style.
|
||||
// e.g. "03 - Deus Vult ..." or "S01E01-Deep Blue"
|
||||
private static readonly Regex EpisodeStartRegex =
|
||||
new(@"^(\d+|S\d+E\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Individual metadata-token patterns used to find where the show name ends
|
||||
// inside a folder name.
|
||||
private static readonly Regex[] MetadataPatterns =
|
||||
@@ -34,7 +52,8 @@ public static class FileNameHelper
|
||||
new(@"^(BD|BluRay|Blu-Ray|WEB|WEB-DL|WEBRip|HDTV|AMZN|NF|DSNP|HULU|MAX)$",RegexOptions.IgnoreCase | RegexOptions.Compiled), // sources
|
||||
new(@"^(Remux|Encode)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // processing
|
||||
new(@"^(HEVC|AVC|x264|x265|H\.?264|H\.?265|VP9)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // video codecs
|
||||
new(@"^(FLAC|AAC|DTS|TrueHD|Atmos|AC3|EAC3|Opus|MP3)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs
|
||||
new(@"^(FLAC|AAC|DTS(-HD)?|TrueHD|Atmos|E?AC3|DDP?|Opus|MP3)(\d(\.\d)?)?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // audio codecs (+ optional channel suffix, e.g. DDP5.1)
|
||||
new(@"^\d\.\d$", RegexOptions.Compiled), // bare channel count, e.g. 5.1
|
||||
new(@"^(HDR|SDR|10bit|8bit|HDR10|DV|DoVi)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // HDR / bit-depth
|
||||
];
|
||||
|
||||
@@ -44,36 +63,101 @@ public static class FileNameHelper
|
||||
/// Computes the final output filename for a given source file path, applying
|
||||
/// (in order):
|
||||
/// <list type="number">
|
||||
/// <item>Show-name injection — if the filename starts with an episode code
|
||||
/// (e.g. S01E01) the show name is derived from the parent folder and
|
||||
/// prepended.</item>
|
||||
/// <item>Release-tag prefix — a <c>[TAG]</c> bracket prefix derived from
|
||||
/// the parent folder name, either from a suffix pattern (<c>-TAG</c>)
|
||||
/// or a prefix pattern (<c>[TAG]</c>).</item>
|
||||
/// <item>Filename suffix tag extraction — a trailing <c>-GroupName</c> on
|
||||
/// the filename stem, 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 the parent folder provides
|
||||
/// no actionable information.
|
||||
/// Returns the original filename unchanged when no transformations apply.
|
||||
/// </summary>
|
||||
public static string ComputeOutputFileName(string sourceFilePath)
|
||||
{
|
||||
string fileName = Path.GetFileName(sourceFilePath);
|
||||
string? folder = Path.GetDirectoryName(sourceFilePath);
|
||||
if (string.IsNullOrEmpty(folder)) return fileName;
|
||||
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) ?? "");
|
||||
|
||||
string folderName = Path.GetFileName(folder);
|
||||
if (string.IsNullOrEmpty(folderName)) return fileName;
|
||||
|
||||
// 1. Inject show name when only an episode code is present.
|
||||
string fileNameNoExt = Path.GetFileNameWithoutExtension(fileName);
|
||||
if (EpisodeOnlyRegex.IsMatch(fileNameNoExt))
|
||||
// 1. Extract in-filename suffix tag and remove it from the stem.
|
||||
// e.g. "...HEVC-DemiHuman" → tag="DemiHuman", stem="...HEVC"
|
||||
string? fileTag = null;
|
||||
var fileSuffixMatch = FileSuffixTagRegex.Match(stemNoExt);
|
||||
if (fileSuffixMatch.Success)
|
||||
{
|
||||
string showName = ExtractShowName(folderName);
|
||||
if (!string.IsNullOrEmpty(showName))
|
||||
fileName = showName + " " + fileName;
|
||||
fileTag = fileSuffixMatch.Groups[1].Value;
|
||||
stemNoExt = stemNoExt[..fileSuffixMatch.Index];
|
||||
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. Prepend release tag if not already present.
|
||||
string? tag = ExtractReleaseTag(folderName);
|
||||
// 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.
|
||||
if (!string.IsNullOrEmpty(tag))
|
||||
{
|
||||
string tagPrefix = $"[{tag}]";
|
||||
@@ -91,7 +175,7 @@ public static class FileNameHelper
|
||||
/// </summary>
|
||||
public static string? ExtractReleaseTag(string folderName)
|
||||
{
|
||||
var suffixMatch = SuffixTagRegex.Match(folderName);
|
||||
var suffixMatch = FolderSuffixTagRegex.Match(folderName);
|
||||
if (suffixMatch.Success) return suffixMatch.Groups[1].Value;
|
||||
|
||||
var prefixMatch = PrefixTagRegex.Match(folderName);
|
||||
@@ -102,16 +186,29 @@ public static class FileNameHelper
|
||||
|
||||
/// <summary>
|
||||
/// Derives the show name from a folder name by stripping any release tag
|
||||
/// (prefix or suffix) and then taking all words that precede the first
|
||||
/// recognised metadata token (season, quality, source, codec, etc.).
|
||||
/// (prefix or suffix) and then:
|
||||
/// <list type="bullet">
|
||||
/// <item>If the remainder contains brackets (e.g. <c>[BD][1080p-FLAC]</c>),
|
||||
/// returns the text that precedes the first bracket.</item>
|
||||
/// <item>Otherwise splits on spaces and collects words up to the first
|
||||
/// recognised metadata token (season, quality, source, codec, etc.).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string ExtractShowName(string folderName)
|
||||
{
|
||||
// Strip release tags first.
|
||||
string name = SuffixTagRegex.Replace(folderName, "").Trim();
|
||||
string name = FolderSuffixTagRegex.Replace(folderName, "").Trim();
|
||||
name = PrefixTagRegex.Replace(name, "").Trim();
|
||||
|
||||
// Collect words up to the first metadata token.
|
||||
// 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)
|
||||
|
||||
+51
-8
@@ -93,9 +93,12 @@ public class MkvService
|
||||
|
||||
/// <summary>
|
||||
/// Builds the list of <see cref="TrackItem"/> objects for a file. Video
|
||||
/// tracks are always pre-checked. Exactly one audio track is pre-checked:
|
||||
/// among Japanese/Chinese/undefined-language tracks, a stereo (2-channel)
|
||||
/// track is preferred, otherwise the first matching track in file order.
|
||||
/// tracks are always pre-checked. Exactly one audio track is always
|
||||
/// pre-checked: among Japanese/Chinese/undefined-language tracks, a
|
||||
/// stereo (2-channel) track is preferred, otherwise the first matching
|
||||
/// track in file order. If no such track exists (e.g. only English
|
||||
/// audio), the very first audio track in the file is pre-checked instead,
|
||||
/// so a file is never left with no audio track selected.
|
||||
/// Subtitle tracks are never pre-checked — the user picks one via the
|
||||
/// track table, and the choice propagates to other queued files.
|
||||
/// </summary>
|
||||
@@ -108,7 +111,8 @@ public class MkvService
|
||||
.ToList();
|
||||
|
||||
MkvTrack? preferredAudio = eligibleAudio.FirstOrDefault(t => t.Properties?.AudioChannels == 2)
|
||||
?? eligibleAudio.FirstOrDefault();
|
||||
?? eligibleAudio.FirstOrDefault()
|
||||
?? audioTracks.FirstOrDefault();
|
||||
|
||||
var items = info.Tracks.Select(t =>
|
||||
{
|
||||
@@ -258,12 +262,13 @@ public class MkvService
|
||||
IReadOnlyList<int> videoKeepIds,
|
||||
IReadOnlyList<int> audioKeepIds,
|
||||
IReadOnlyList<int> subtitleKeepIds,
|
||||
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles = null,
|
||||
IProgress<ProcessingProgress>? progress = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return StripTracksInternalAsync(
|
||||
filePath, Path.GetFileName(filePath), outputFilePath,
|
||||
videoKeepIds, audioKeepIds, subtitleKeepIds,
|
||||
videoKeepIds, audioKeepIds, subtitleKeepIds, externalSubtitles,
|
||||
progress, ct);
|
||||
}
|
||||
|
||||
@@ -281,6 +286,7 @@ public class MkvService
|
||||
null, // null = keep all video
|
||||
scan.AudioKeepIds,
|
||||
scan.SubtitleKeepIds,
|
||||
null,
|
||||
progress, ct);
|
||||
}
|
||||
|
||||
@@ -386,6 +392,7 @@ public class MkvService
|
||||
IReadOnlyList<int>? videoKeepIds,
|
||||
IReadOnlyList<int>? audioKeepIds,
|
||||
IReadOnlyList<int>? subtitleKeepIds,
|
||||
IReadOnlyList<ExternalSubtitleTrack>? externalSubtitles,
|
||||
IProgress<ProcessingProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -422,16 +429,52 @@ public class MkvService
|
||||
AddTrackTypeArgs(psi, "--audio-tracks", "--no-audio", audioKeepIds);
|
||||
AddTrackTypeArgs(psi, "--subtitle-tracks", "--no-subtitles", subtitleKeepIds);
|
||||
|
||||
// The one subtitle track the user chose to keep is always flagged
|
||||
// as the default subtitle track in the output.
|
||||
// Only one subtitle track in the whole output should ever be
|
||||
// flagged default. If any external subtitle is marked default,
|
||||
// it takes priority over the kept internal subtitle track.
|
||||
bool hasExternalDefault = externalSubtitles != null && externalSubtitles.Any(s => s.IsDefault);
|
||||
|
||||
if (subtitleKeepIds != null && subtitleKeepIds.Count == 1)
|
||||
{
|
||||
psi.ArgumentList.Add("--default-track-flag");
|
||||
psi.ArgumentList.Add($"{subtitleKeepIds[0]}:yes");
|
||||
psi.ArgumentList.Add(hasExternalDefault ? $"{subtitleKeepIds[0]}:no" : $"{subtitleKeepIds[0]}:yes");
|
||||
}
|
||||
|
||||
psi.ArgumentList.Add(filePath);
|
||||
|
||||
// Append each matched external subtitle file as its own input,
|
||||
// with per-file options (language/name/flags) scoped to it.
|
||||
if (externalSubtitles != null)
|
||||
{
|
||||
foreach (var sub in externalSubtitles)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sub.Language) && !string.Equals(sub.Language, "und", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
psi.ArgumentList.Add("--language");
|
||||
psi.ArgumentList.Add($"0:{sub.Language}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(sub.TrackName))
|
||||
{
|
||||
psi.ArgumentList.Add("--track-name");
|
||||
psi.ArgumentList.Add($"0:{sub.TrackName}");
|
||||
}
|
||||
if (sub.Forced)
|
||||
{
|
||||
psi.ArgumentList.Add("--forced-display-flag");
|
||||
psi.ArgumentList.Add("0:yes");
|
||||
}
|
||||
if (sub.HearingImpaired)
|
||||
{
|
||||
psi.ArgumentList.Add("--hearing-impaired-flag");
|
||||
psi.ArgumentList.Add("0:yes");
|
||||
}
|
||||
psi.ArgumentList.Add("--default-track-flag");
|
||||
psi.ArgumentList.Add(sub.IsDefault ? "0:yes" : "0:no");
|
||||
|
||||
psi.ArgumentList.Add(sub.FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
|
||||
|
||||
@@ -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,61 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Futonizer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Recognises subtitle files by extension and extracts a normalized episode
|
||||
/// code (e.g. "S04E06") from a filename, used to match dropped subtitle
|
||||
/// files to the video file with the same episode code.
|
||||
/// </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);
|
||||
|
||||
/// <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 episode code is found.
|
||||
/// </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}";
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ public partial class ProgressWindow : FluentWindow
|
||||
.Where(t => t.Type == "subtitles" && t.Copy)
|
||||
.Select(t => t.Id).ToList();
|
||||
|
||||
bool nothingToStrip = file.Tracks.All(t => t.Copy);
|
||||
bool nothingToStrip = file.Tracks.All(t => t.Copy) && file.ExternalSubtitles.Count == 0;
|
||||
|
||||
var progress = new Progress<ProcessingProgress>(p =>
|
||||
{
|
||||
@@ -187,10 +187,14 @@ public partial class ProgressWindow : FluentWindow
|
||||
: await service.StripTracksAsync(
|
||||
file.FilePath, outputFilePath,
|
||||
videoIds, audioIds, subIds,
|
||||
file.ExternalSubtitles.ToList(),
|
||||
progress, ct);
|
||||
|
||||
string tag = result.Success ? "[DONE]" : "[FAIL]";
|
||||
AppendLog($"{tag} {Path.GetFileName(result.OutputFilePath)} — {result.Message}");
|
||||
string extInfo = file.ExternalSubtitles.Count > 0
|
||||
? $" (+{file.ExternalSubtitles.Count} external sub{(file.ExternalSubtitles.Count == 1 ? "" : "s")})"
|
||||
: string.Empty;
|
||||
AppendLog($"{tag} {Path.GetFileName(result.OutputFilePath)}{extInfo} — {result.Message}");
|
||||
|
||||
HistoryService.Add(new HistoryEntry
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user