Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emby Toolbox 2026-05-12 21:33:47 +05:00
commit 6264b487fe
133 changed files with 25829 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
bin/
obj/
.vs/
_build_temp/
_buildcheck/
*.user
*.suo
*.userosscache
*.sln.docstates

34
EmbyToolbox.sln Normal file
View File

@ -0,0 +1,34 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmbyToolbox", "EmbyToolbox\EmbyToolbox.csproj", "{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x64.ActiveCfg = Debug|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x64.Build.0 = Debug|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x86.ActiveCfg = Debug|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x86.Build.0 = Debug|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|Any CPU.Build.0 = Release|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x64.ActiveCfg = Release|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x64.Build.0 = Release|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x86.ActiveCfg = Release|Any CPU
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

13
EmbyToolbox/App.xaml Normal file
View File

@ -0,0 +1,13 @@
<Application x:Class="EmbyToolbox.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/UiColors.xaml" />
<ResourceDictionary Source="Themes/UiComponentStyles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

16
EmbyToolbox/App.xaml.cs Normal file
View File

@ -0,0 +1,16 @@
using System.Windows;
using EmbyToolbox.Interop;
using EmbyToolbox.Services;
namespace EmbyToolbox;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
_ = AppUserModelIdRegistration.TryRegister(NotificationService.ToastAppUserModelId);
base.OnStartup(e);
}
}

View File

@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@ -0,0 +1,47 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
namespace EmbyToolbox.Behaviors;
/// <summary>Всплывает <see cref="Button.ContextMenu"/> по левому клику вместо ПКМ (split-menu кнопка).</summary>
public static class ContextMenuOpenOnButtonClickBehavior
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(ContextMenuOpenOnButtonClickBehavior),
new PropertyMetadata(false, OnIsEnabledChanged));
public static void SetIsEnabled(Button obj, bool value) => obj.SetValue(IsEnabledProperty, value);
public static bool GetIsEnabled(Button obj) => (bool)obj.GetValue(IsEnabledProperty);
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not Button btn)
{
return;
}
btn.Click -= OnButtonClick;
if (e.NewValue is true)
{
btn.Click += OnButtonClick;
}
}
private static void OnButtonClick(object sender, RoutedEventArgs e)
{
if (sender is not Button btn || btn.ContextMenu is not ContextMenu menu)
{
return;
}
menu.PlacementTarget = btn;
menu.Placement = PlacementMode.Bottom;
menu.IsOpen = true;
e.Handled = true;
}
}

View File

@ -0,0 +1,102 @@
using System.Windows;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Behaviors;
public static class ConversionQueueDropTargetBehavior
{
public static readonly DependencyProperty IsDropTargetEnabledProperty = DependencyProperty.RegisterAttached(
"IsDropTargetEnabled",
typeof(bool),
typeof(ConversionQueueDropTargetBehavior),
new PropertyMetadata(false, OnIsDropTargetEnabledChanged));
public static void SetIsDropTargetEnabled(DependencyObject d, bool value) => d.SetValue(IsDropTargetEnabledProperty, value);
public static bool GetIsDropTargetEnabled(DependencyObject d) => (bool)d.GetValue(IsDropTargetEnabledProperty);
private static void OnIsDropTargetEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not UIElement el)
{
return;
}
if (e.OldValue is true)
{
el.PreviewDragOver -= OnPreviewDragOver;
el.DragLeave -= OnDragLeave;
el.Drop -= OnDrop;
}
if (e.NewValue is not true)
{
return;
}
el.AllowDrop = true;
el.PreviewDragOver += OnPreviewDragOver;
el.DragLeave += OnDragLeave;
el.Drop += OnDrop;
}
private static void OnDragLeave(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe)
{
return;
}
if (fe.DataContext is ConversionViewModel vm)
{
vm.IsQueueDropHighlight = false;
}
}
private static void OnPreviewDragOver(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe)
{
return;
}
if (fe.DataContext is not ConversionViewModel vm)
{
return;
}
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
e.Handled = true;
vm.IsQueueDropHighlight = true;
return;
}
e.Effects = DragDropEffects.None;
vm.IsQueueDropHighlight = false;
}
private static void OnDrop(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe)
{
return;
}
if (fe.DataContext is not ConversionViewModel vm)
{
return;
}
vm.IsQueueDropHighlight = false;
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
{
e.Handled = true;
return;
}
vm.ProcessPathsDroppedOnQueue(paths);
e.Handled = true;
}
}

View File

@ -0,0 +1,127 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
namespace EmbyToolbox.Behaviors;
public static class DataGridAutoScrollSelectionBehavior
{
private static readonly DependencyProperty SuppressAutoScrollUntilProperty = DependencyProperty.RegisterAttached(
"SuppressAutoScrollUntil",
typeof(DateTime),
typeof(DataGridAutoScrollSelectionBehavior),
new PropertyMetadata(DateTime.MinValue));
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(DataGridAutoScrollSelectionBehavior),
new PropertyMetadata(false, OnChanged));
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
private static void OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not DataGrid dg)
{
return;
}
if (e.OldValue is true)
{
dg.SelectionChanged -= OnSelectionChanged;
dg.PreviewMouseLeftButtonDown -= OnPreviewMouseLeftButtonDown;
dg.PreviewKeyDown -= OnPreviewKeyDown;
dg.PreviewMouseWheel -= OnPreviewMouseWheel;
}
if (e.NewValue is true)
{
dg.SelectionChanged += OnSelectionChanged;
dg.PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown;
dg.PreviewKeyDown += OnPreviewKeyDown;
dg.PreviewMouseWheel += OnPreviewMouseWheel;
}
}
private static void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is not DataGrid dg)
{
return;
}
var modifiers = Keyboard.Modifiers;
if ((modifiers & (ModifierKeys.Shift | ModifierKeys.Control)) != ModifierKeys.None)
{
SetSuppressAutoScrollUntil(dg, DateTime.UtcNow.AddMilliseconds(350));
}
}
private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (sender is not DataGrid dg)
{
return;
}
if (e.Key is Key.LeftShift or Key.RightShift or Key.LeftCtrl or Key.RightCtrl)
{
SetSuppressAutoScrollUntil(dg, DateTime.UtcNow.AddMilliseconds(350));
}
}
private static void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (sender is not DataGrid dg)
{
return;
}
// Иначе SelectionChanged + ScrollIntoView «дёргают» прокрутку колёсиком.
SetSuppressAutoScrollUntil(dg, DateTime.UtcNow.AddMilliseconds(500));
}
private static void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not DataGrid dg || dg.SelectedItem is null)
{
return;
}
if (ShouldSuppressForUserRangeSelection(dg))
{
return;
}
dg.Dispatcher.BeginInvoke(
DispatcherPriority.Background,
() =>
{
if (dg.SelectedItem is not null)
{
dg.ScrollIntoView(dg.SelectedItem);
}
});
}
private static bool ShouldSuppressForUserRangeSelection(DataGrid dg)
{
var modifiers = Keyboard.Modifiers;
if ((modifiers & (ModifierKeys.Shift | ModifierKeys.Control)) != ModifierKeys.None)
{
return true;
}
return DateTime.UtcNow <= GetSuppressAutoScrollUntil(dg);
}
private static DateTime GetSuppressAutoScrollUntil(DependencyObject obj) =>
(DateTime)obj.GetValue(SuppressAutoScrollUntilProperty);
private static void SetSuppressAutoScrollUntil(DependencyObject obj, DateTime value) =>
obj.SetValue(SuppressAutoScrollUntilProperty, value);
}

View File

@ -0,0 +1,83 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
namespace EmbyToolbox.Behaviors;
public static class DataGridRightClickSelectionBehavior
{
public static readonly DependencyProperty IsEnabledProperty =
DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(DataGridRightClickSelectionBehavior),
new PropertyMetadata(false, OnIsEnabledChanged));
public static bool GetIsEnabled(DependencyObject obj) => (bool)obj.GetValue(IsEnabledProperty);
public static void SetIsEnabled(DependencyObject obj, bool value) => obj.SetValue(IsEnabledProperty, value);
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not DataGrid grid)
{
return;
}
grid.PreviewMouseRightButtonDown -= OnPreviewMouseRightButtonDown;
if (e.NewValue is true)
{
grid.PreviewMouseRightButtonDown += OnPreviewMouseRightButtonDown;
}
}
private static void OnPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is not DataGrid grid)
{
return;
}
var row = FindAncestor<DataGridRow>(e.OriginalSource as DependencyObject);
if (row?.Item is null)
{
return;
}
// Если клик по уже выделенной строке — сохраняем множественное выделение.
if (row.IsSelected)
{
row.Focus();
return;
}
// Если клик по невыделенной строке — выделяем только ее.
grid.SelectedItems.Clear();
row.IsSelected = true;
row.Focus();
}
private static T? FindAncestor<T>(DependencyObject? start)
where T : DependencyObject
{
var current = start;
while (current is not null)
{
if (current is T found)
{
return found;
}
current = current switch
{
Visual v => VisualTreeHelper.GetParent(v),
FrameworkContentElement fce => fce.Parent,
_ => null
};
}
return null;
}
}

View File

@ -0,0 +1,105 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using EmbyToolbox.Models;
namespace EmbyToolbox.Behaviors;
/// <summary>Двойной клик по строке DataGrid: вызов ICommand с аргументом <see cref="ConversionQueueItem"/>.</summary>
public static class DataGridRowDoubleClickCommandBehavior
{
public static readonly DependencyProperty CommandProperty = DependencyProperty.RegisterAttached(
"Command",
typeof(ICommand),
typeof(DataGridRowDoubleClickCommandBehavior),
new PropertyMetadata(null, OnCommandChanged));
public static ICommand? GetCommand(DependencyObject d) => (ICommand?)d.GetValue(CommandProperty);
public static void SetCommand(DependencyObject d, ICommand? v) => d.SetValue(CommandProperty, v);
private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not DataGrid dg)
{
return;
}
if (e.OldValue is not null)
{
dg.MouseDoubleClick -= OnMouseDoubleClick;
}
if (e.NewValue is not null)
{
dg.MouseDoubleClick += OnMouseDoubleClick;
}
}
private static void OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is not DataGrid dg)
{
return;
}
if (IsInCombo((DependencyObject)e.OriginalSource))
{
return;
}
var row = GetRow((DependencyObject)e.OriginalSource);
if (row is null)
{
return;
}
if (row.Item is not ConversionQueueItem item)
{
return;
}
var cmd = GetCommand(dg);
if (cmd is null)
{
return;
}
if (cmd.CanExecute(item))
{
cmd.Execute(item);
}
e.Handled = true;
}
private static DataGridRow? GetRow(DependencyObject? current)
{
while (current is not null)
{
if (current is DataGridRow r)
{
return r;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static bool IsInCombo(DependencyObject? d)
{
while (d is not null)
{
if (d is ComboBox)
{
return true;
}
d = VisualTreeHelper.GetParent(d);
}
return false;
}
}

View File

@ -0,0 +1,55 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace EmbyToolbox.Behaviors;
public static class DataGridSelectionChangedCommandBehavior
{
public static readonly DependencyProperty CommandProperty =
DependencyProperty.RegisterAttached(
"Command",
typeof(ICommand),
typeof(DataGridSelectionChangedCommandBehavior),
new PropertyMetadata(null, OnCommandChanged));
public static void SetCommand(DependencyObject element, ICommand? value) =>
element.SetValue(CommandProperty, value);
public static ICommand? GetCommand(DependencyObject element) =>
(ICommand?)element.GetValue(CommandProperty);
private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not DataGrid grid)
{
return;
}
grid.SelectionChanged -= OnSelectionChanged;
if (e.NewValue is ICommand)
{
grid.SelectionChanged += OnSelectionChanged;
}
}
private static void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not DataGrid grid)
{
return;
}
var cmd = GetCommand(grid);
if (cmd is null)
{
return;
}
var parameter = grid.SelectedItems;
if (cmd.CanExecute(parameter))
{
cmd.Execute(parameter);
}
}
}

View File

@ -0,0 +1,72 @@
using System.Windows;
using System.Windows.Input;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Behaviors;
public static class FileDropBehavior
{
public static readonly DependencyProperty DropCommandProperty =
DependencyProperty.RegisterAttached(
"DropCommand",
typeof(RelayCommand),
typeof(FileDropBehavior),
new PropertyMetadata(null, OnDropCommandChanged));
public static void SetDropCommand(DependencyObject element, RelayCommand? value)
{
element.SetValue(DropCommandProperty, value);
}
public static RelayCommand? GetDropCommand(DependencyObject element)
{
return (RelayCommand?)element.GetValue(DropCommandProperty);
}
private static void OnDropCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not UIElement element)
{
return;
}
element.AllowDrop = e.NewValue is not null;
element.PreviewDragOver -= OnPreviewDragOver;
element.Drop -= OnDrop;
if (e.NewValue is not null)
{
element.PreviewDragOver += OnPreviewDragOver;
element.Drop += OnDrop;
}
}
private static void OnPreviewDragOver(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
e.Handled = true;
}
}
private static void OnDrop(object sender, DragEventArgs e)
{
if (sender is not DependencyObject d)
{
return;
}
var command = GetDropCommand(d);
if (command is null)
{
return;
}
if (e.Data.GetData(DataFormats.FileDrop) is string[] paths && command.CanExecute(paths))
{
command.Execute(paths);
e.Handled = true;
}
}
}

View File

@ -0,0 +1,121 @@
using System.Collections.Specialized;
using System.Windows;
using System.Windows.Controls;
namespace EmbyToolbox.Behaviors;
public static class ListBoxAutoScrollBehavior
{
public static readonly DependencyProperty AutoScrollToEndProperty =
DependencyProperty.RegisterAttached(
"AutoScrollToEnd",
typeof(bool),
typeof(ListBoxAutoScrollBehavior),
new PropertyMetadata(false, OnAutoScrollToEndChanged));
public static bool GetAutoScrollToEnd(DependencyObject obj)
{
return (bool)obj.GetValue(AutoScrollToEndProperty);
}
public static void SetAutoScrollToEnd(DependencyObject obj, bool value)
{
obj.SetValue(AutoScrollToEndProperty, value);
}
private static void OnAutoScrollToEndChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ListBox listBox)
{
return;
}
if ((bool)e.NewValue)
{
listBox.Loaded += ListBoxOnLoaded;
}
else
{
listBox.Loaded -= ListBoxOnLoaded;
UnhookCollection(listBox);
}
}
private static void ListBoxOnLoaded(object sender, RoutedEventArgs e)
{
if (sender is not ListBox listBox)
{
return;
}
HookCollection(listBox);
ScrollToLast(listBox);
}
private static void HookCollection(ListBox listBox)
{
if (listBox.ItemsSource is INotifyCollectionChanged collection)
{
collection.CollectionChanged -= OnCollectionChanged;
collection.CollectionChanged += OnCollectionChanged;
}
}
private static void UnhookCollection(ListBox listBox)
{
if (listBox.ItemsSource is INotifyCollectionChanged collection)
{
collection.CollectionChanged -= OnCollectionChanged;
}
}
private static void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (sender is not INotifyCollectionChanged collection)
{
return;
}
foreach (Window window in Application.Current.Windows)
{
var listBox = FindListBoxForCollection(window, collection);
if (listBox is not null)
{
ScrollToLast(listBox);
return;
}
}
}
private static ListBox? FindListBoxForCollection(DependencyObject root, INotifyCollectionChanged collection)
{
if (root is ListBox listBox && ReferenceEquals(listBox.ItemsSource, collection))
{
return listBox;
}
var childrenCount = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < childrenCount; i++)
{
var child = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
var found = FindListBoxForCollection(child, collection);
if (found is not null)
{
return found;
}
}
return null;
}
private static void ScrollToLast(ListBox listBox)
{
if (listBox.Items.Count <= 0)
{
return;
}
var last = listBox.Items[listBox.Items.Count - 1];
listBox.ScrollIntoView(last);
}
}

View File

@ -0,0 +1,97 @@
using System.Windows;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Behaviors;
public static class MergeDropTargetBehavior
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(MergeDropTargetBehavior),
new PropertyMetadata(false, OnIsEnabledChanged));
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FrameworkElement fe)
{
return;
}
fe.AllowDrop = false;
fe.PreviewDragOver -= OnPreviewDragOver;
fe.PreviewDragLeave -= OnPreviewDragLeave;
fe.PreviewDrop -= OnPreviewDrop;
if (e.NewValue is true)
{
fe.AllowDrop = true;
fe.PreviewDragOver += OnPreviewDragOver;
fe.PreviewDragLeave += OnPreviewDragLeave;
fe.PreviewDrop += OnPreviewDrop;
}
}
private static void OnPreviewDragOver(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not MergeViewModel vm)
{
return;
}
if (vm.IsRunning)
{
e.Effects = DragDropEffects.None;
vm.IsMergeDropHighlight = false;
return;
}
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
e.Handled = true;
vm.IsMergeDropHighlight = true;
}
else
{
e.Effects = DragDropEffects.None;
vm.IsMergeDropHighlight = false;
}
}
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not MergeViewModel vm)
{
return;
}
vm.IsMergeDropHighlight = false;
}
private static void OnPreviewDrop(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not MergeViewModel vm)
{
return;
}
vm.IsMergeDropHighlight = false;
if (vm.IsRunning)
{
e.Handled = true;
return;
}
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
{
e.Handled = true;
return;
}
vm.ApplyDroppedPaths(paths);
e.Handled = true;
}
}

View File

@ -0,0 +1,85 @@
using System.Windows;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Behaviors;
public static class SeriesRenamerDropTargetBehavior
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(SeriesRenamerDropTargetBehavior),
new PropertyMetadata(false, OnIsEnabledChanged));
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FrameworkElement fe)
{
return;
}
fe.AllowDrop = false;
fe.PreviewDragOver -= OnPreviewDragOver;
fe.PreviewDragLeave -= OnPreviewDragLeave;
fe.PreviewDrop -= OnPreviewDrop;
if (e.NewValue is true)
{
fe.AllowDrop = true;
fe.PreviewDragOver += OnPreviewDragOver;
fe.PreviewDragLeave += OnPreviewDragLeave;
fe.PreviewDrop += OnPreviewDrop;
}
}
private static void OnPreviewDragOver(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not SeriesRenamerViewModel vm)
{
return;
}
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
e.Handled = true;
vm.IsRootTreeDragOver = true;
}
else
{
e.Effects = DragDropEffects.None;
vm.IsRootTreeDragOver = false;
}
}
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not SeriesRenamerViewModel vm)
{
return;
}
vm.IsRootTreeDragOver = false;
}
private static void OnPreviewDrop(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not SeriesRenamerViewModel vm)
{
return;
}
vm.IsRootTreeDragOver = false;
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
{
e.Handled = true;
return;
}
vm.ApplyDroppedPaths(paths);
e.Handled = true;
}
}

View File

@ -0,0 +1,50 @@
using System.Windows;
using System.Windows.Controls;
namespace EmbyToolbox.Behaviors;
public static class TextBoxAutoScrollBehavior
{
public static readonly DependencyProperty AutoScrollToEndProperty =
DependencyProperty.RegisterAttached(
"AutoScrollToEnd",
typeof(bool),
typeof(TextBoxAutoScrollBehavior),
new PropertyMetadata(false, OnAutoScrollToEndChanged));
public static bool GetAutoScrollToEnd(DependencyObject obj)
{
return (bool)obj.GetValue(AutoScrollToEndProperty);
}
public static void SetAutoScrollToEnd(DependencyObject obj, bool value)
{
obj.SetValue(AutoScrollToEndProperty, value);
}
private static void OnAutoScrollToEndChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TextBox textBox)
{
return;
}
if ((bool)e.NewValue)
{
textBox.TextChanged += OnTextBoxTextChanged;
textBox.ScrollToEnd();
}
else
{
textBox.TextChanged -= OnTextBoxTextChanged;
}
}
private static void OnTextBoxTextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox)
{
textBox.ScrollToEnd();
}
}
}

View File

@ -0,0 +1,132 @@
using System.IO;
using System.Windows;
using EmbyToolbox.Services;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Behaviors;
public static class TrackExtractionDropTargetBehavior
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(TrackExtractionDropTargetBehavior),
new PropertyMetadata(false, OnIsEnabledChanged));
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FrameworkElement fe)
{
return;
}
fe.AllowDrop = false;
fe.PreviewDragOver -= OnPreviewDragOver;
fe.PreviewDragLeave -= OnPreviewDragLeave;
fe.PreviewDrop -= OnPreviewDrop;
if (e.NewValue is true)
{
fe.AllowDrop = true;
fe.PreviewDragOver += OnPreviewDragOver;
fe.PreviewDragLeave += OnPreviewDragLeave;
fe.PreviewDrop += OnPreviewDrop;
}
}
private static void OnPreviewDragOver(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not TrackExtractionViewModel vm)
{
return;
}
if (vm.IsBusy)
{
e.Effects = DragDropEffects.None;
vm.IsDropHighlight = false;
return;
}
if (!e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.None;
vm.IsDropHighlight = false;
return;
}
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
if (!HasSupportedDrop(paths))
{
e.Effects = DragDropEffects.None;
vm.IsDropHighlight = false;
return;
}
e.Effects = DragDropEffects.Copy;
e.Handled = true;
vm.IsDropHighlight = true;
}
private static bool HasSupportedDrop(string[] paths)
{
if (paths is null || paths.Length == 0)
{
return false;
}
foreach (var raw in paths)
{
try
{
var full = Path.GetFullPath(raw);
if (File.Exists(full) && TrackExtractionFormats.IsSupportedPath(full))
{
return true;
}
if (Directory.Exists(full))
{
return true;
}
}
catch
{
return false;
}
}
return false;
}
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not TrackExtractionViewModel vm)
{
return;
}
vm.IsDropHighlight = false;
}
private static void OnPreviewDrop(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not TrackExtractionViewModel vm)
{
return;
}
vm.IsDropHighlight = false;
if (vm.IsBusy || e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
{
e.Handled = true;
return;
}
vm.ApplyDroppedPaths(paths);
e.Handled = true;
}
}

View File

@ -0,0 +1,166 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace EmbyToolbox.Behaviors;
public static class TreeViewScrollSyncBehavior
{
public static readonly DependencyProperty SyncGroupProperty =
DependencyProperty.RegisterAttached(
"SyncGroup",
typeof(string),
typeof(TreeViewScrollSyncBehavior),
new PropertyMetadata(string.Empty, OnSyncGroupChanged));
private static readonly Dictionary<string, List<WeakReference<TreeView>>> Groups = new(StringComparer.Ordinal);
private static bool _syncing;
public static string GetSyncGroup(DependencyObject obj) => (string)obj.GetValue(SyncGroupProperty);
public static void SetSyncGroup(DependencyObject obj, string value) => obj.SetValue(SyncGroupProperty, value);
private static void OnSyncGroupChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TreeView treeView)
{
return;
}
treeView.Loaded -= TreeViewOnLoaded;
treeView.Unloaded -= TreeViewOnUnloaded;
if (!string.IsNullOrWhiteSpace(e.NewValue as string))
{
treeView.Loaded += TreeViewOnLoaded;
treeView.Unloaded += TreeViewOnUnloaded;
}
}
private static void TreeViewOnLoaded(object sender, RoutedEventArgs e)
{
if (sender is not TreeView tree)
{
return;
}
var group = GetSyncGroup(tree);
if (string.IsNullOrWhiteSpace(group))
{
return;
}
if (!Groups.TryGetValue(group, out var list))
{
list = new List<WeakReference<TreeView>>();
Groups[group] = list;
}
list.Add(new WeakReference<TreeView>(tree));
var scroll = FindScrollViewer(tree);
if (scroll is not null)
{
scroll.ScrollChanged -= OnScrollChanged;
scroll.ScrollChanged += OnScrollChanged;
}
}
private static void TreeViewOnUnloaded(object sender, RoutedEventArgs e)
{
if (sender is not TreeView tree)
{
return;
}
var scroll = FindScrollViewer(tree);
if (scroll is not null)
{
scroll.ScrollChanged -= OnScrollChanged;
}
}
private static void OnScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (_syncing || e.VerticalChange == 0 || sender is not ScrollViewer sourceScroll)
{
return;
}
var sourceTree = FindAncestor<TreeView>(sourceScroll);
if (sourceTree is null)
{
return;
}
var group = GetSyncGroup(sourceTree);
if (string.IsNullOrWhiteSpace(group) || !Groups.TryGetValue(group, out var members))
{
return;
}
try
{
_syncing = true;
foreach (var weak in members.ToList())
{
if (!weak.TryGetTarget(out var targetTree))
{
members.Remove(weak);
continue;
}
if (ReferenceEquals(targetTree, sourceTree))
{
continue;
}
var targetScroll = FindScrollViewer(targetTree);
if (targetScroll is not null)
{
targetScroll.ScrollToVerticalOffset(sourceScroll.VerticalOffset);
}
}
}
finally
{
_syncing = false;
}
}
private static ScrollViewer? FindScrollViewer(DependencyObject root)
{
if (root is ScrollViewer sv)
{
return sv;
}
var count = VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(root, i);
var found = FindScrollViewer(child);
if (found is not null)
{
return found;
}
}
return null;
}
private static T? FindAncestor<T>(DependencyObject node) where T : DependencyObject
{
var current = node;
while (current is not null)
{
if (current is T t)
{
return t;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
}

View File

@ -0,0 +1,92 @@
using System.Windows;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Behaviors;
public static class VideoInfoDropTargetBehavior
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(VideoInfoDropTargetBehavior),
new PropertyMetadata(false, OnIsEnabledChanged));
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FrameworkElement fe)
{
return;
}
fe.AllowDrop = false;
fe.PreviewDragOver -= OnPreviewDragOver;
fe.PreviewDragLeave -= OnPreviewDragLeave;
fe.PreviewDrop -= OnPreviewDrop;
if (e.NewValue is true)
{
fe.AllowDrop = true;
fe.PreviewDragOver += OnPreviewDragOver;
fe.PreviewDragLeave += OnPreviewDragLeave;
fe.PreviewDrop += OnPreviewDrop;
}
}
private static void OnPreviewDragOver(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not VideoInfoViewModel vm)
{
return;
}
if (vm.IsBusy)
{
e.Effects = DragDropEffects.None;
vm.IsVideoInfoDropHighlight = false;
return;
}
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
e.Handled = true;
vm.IsVideoInfoDropHighlight = true;
}
else
{
e.Effects = DragDropEffects.None;
vm.IsVideoInfoDropHighlight = false;
}
}
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not VideoInfoViewModel vm)
{
return;
}
vm.IsVideoInfoDropHighlight = false;
}
private static void OnPreviewDrop(object sender, DragEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not VideoInfoViewModel vm)
{
return;
}
vm.IsVideoInfoDropHighlight = false;
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
{
e.Handled = true;
return;
}
vm.ApplyDroppedPathsAndAnalyze(paths);
e.Handled = true;
}
}

View File

@ -0,0 +1,17 @@
using System.Globalization;
using System.Windows.Data;
namespace EmbyToolbox.Converters;
public sealed class BooleanNegationConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is bool b ? !b : true;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is bool b ? !b : false;
}
}

View File

@ -0,0 +1,22 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace EmbyToolbox.Converters;
public sealed class BooleanToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var flag = value is true;
if (parameter is string p && string.Equals(p, "Invert", StringComparison.OrdinalIgnoreCase))
{
flag = !flag;
}
return flag ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is Visibility.Visible;
}

View File

@ -0,0 +1,28 @@
using System.Globalization;
using System.Windows.Data;
using EmbyToolbox.Models;
namespace EmbyToolbox.Converters;
public sealed class TrackActionKindToRussianConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not TrackActionKind action)
{
return string.Empty;
}
return action switch
{
TrackActionKind.Keep => "Оставить",
TrackActionKind.Convert => "Конвертировать",
TrackActionKind.Remove => "Удалить",
TrackActionKind.Add => "Добавить",
_ => action.ToString()
};
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
Binding.DoNothing;
}

View File

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<ApplicationIcon>Resources\AppIcon.ico</ApplicationIcon>
<PackageIcon>icons8-emby-96.png</PackageIcon>
</PropertyGroup>
<ItemGroup>
<Content Include="Tools\ffmpeg.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Tools\ffprobe.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Resource Include="Resources\AppIcon.ico" />
</ItemGroup>
<ItemGroup>
<None Update="Resources\Icons\icons8-emby-96.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,49 @@
using System.Runtime.InteropServices;
namespace EmbyToolbox.Interop;
/// <summary>Вызов SetCurrentProcessExplicitAppUserModelID до показа окон/unpackaged toasts Windows.</summary>
internal static class AppUserModelIdRegistration
{
[DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = false)]
private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
public static string? LastRegisteredId { get; private set; }
public static int LastHr { get; private set; }
public static string? LastDiagnostics { get; private set; }
/// <returns>true, если получен код 0 (S_OK).</returns>
public static bool TryRegister(string appUserModelId)
{
LastDiagnostics = null;
if (!OperatingSystem.IsWindowsVersionAtLeast(6, 1))
{
LastDiagnostics = "требуется Windows.";
LastHr = -1;
return false;
}
try
{
var hr = SetCurrentProcessExplicitAppUserModelID(appUserModelId);
LastHr = hr;
LastRegisteredId = hr == 0 ? appUserModelId : null;
if (hr != 0)
{
LastDiagnostics =
$"SetCurrentProcessExplicitAppUserModelID вернул 0x{hr:X8} для «{appUserModelId}».";
return false;
}
return true;
}
catch (Exception ex)
{
LastHr = -2;
LastDiagnostics = $"{ex.GetType().Name}: {ex.Message}";
LastRegisteredId = null;
return false;
}
}
}

1062
EmbyToolbox/MainWindow.xaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox;
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainWindowViewModel();
}
private void OnJsonTreeItemPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
var dependencyObject = e.OriginalSource as DependencyObject;
while (dependencyObject is not null && dependencyObject is not TreeViewItem)
{
dependencyObject = VisualTreeHelper.GetParent(dependencyObject);
}
if (dependencyObject is TreeViewItem item)
{
item.IsSelected = true;
item.Focus();
}
}
}

View File

@ -0,0 +1,6 @@
namespace EmbyToolbox.Models;
public sealed class AddFilesOptions
{
public bool RemoveForeignAudioAndSubtitles { get; init; }
}

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace EmbyToolbox.Models;
/// <summary>План шагов конвертации и краткое отображение.</summary>
public sealed class ConversionPlan
{
public IReadOnlyList<string> StepDescriptions { get; init; } = Array.Empty<string>();
public IReadOnlyList<ConversionTrackPlan> TrackParts { get; init; } = Array.Empty<ConversionTrackPlan>();
public ConversionPlanActionStats ActionStats { get; init; } = default;
public string ShortSummary { get; init; } = string.Empty;
public bool SuggestsSkip { get; init; }
public bool HasRealActions { get; init; }
public string TargetVideoBitrateMode { get; init; } = string.Empty;
public int? TargetVideoBitrateKbps { get; init; }
/// <summary>MPEG-TS → MKV: первичная попытка copy с genpts; при ошибке timestamp возможен fallback на перекодирование видео.</summary>
public bool RequiresTimestampFix { get; init; }
}

View File

@ -0,0 +1,20 @@
namespace EmbyToolbox.Models;
/// <summary>Тип элемента плана (для расширения, отображение в UI — строки в <see cref="ConversionPlan"/>).</summary>
public enum ConversionPlanAction
{
None,
Skip,
RemuxToMkv,
RemuxToMp4,
ConvertVideo,
ConvertPixelFormat,
Resize,
LimitFps,
ConvertAudio,
RemoveNonRusAudio,
RemoveNonRusSubtitles,
AddExternalAudio,
AddExternalSubtitles,
RemoveDataStreams
}

View File

@ -0,0 +1,77 @@
namespace EmbyToolbox.Models;
/// <summary>Сводка количеств операций в плане (источник — <see cref="ConversionTaskOverride"/> + профиль).</summary>
public readonly record struct ConversionPlanActionStats(
int Add,
int Remove,
int ConvertAudio,
int ConvertVideo,
int SubtitleRemove,
int SubtitleConvert,
int SubtitleKeep)
{
public static ConversionPlanActionStats FromOverrides(ConversionTaskOverride? ovr)
{
if (ovr is null)
{
return default;
}
var add = 0;
var remove = 0;
var cAudio = 0;
var cVideo = 0;
var sRem = 0;
var sConv = 0;
var sKeep = 0;
foreach (var t in ovr.TrackOverrides)
{
if (t.Source == SourceKind.External)
{
if (t.Action == TrackActionKind.Add)
{
add++;
}
}
else
{
if (t.Action == TrackActionKind.Remove)
{
remove++;
}
if (t.Action == TrackActionKind.Convert)
{
if (t.StreamKind == MediaStreamKind.Audio)
{
cAudio++;
}
if (t.StreamKind == MediaStreamKind.Video)
{
cVideo++;
}
if (t.StreamKind == MediaStreamKind.Subtitle)
{
sConv++;
}
}
if (t.StreamKind == MediaStreamKind.Subtitle)
{
if (t.Action == TrackActionKind.Remove)
{
sRem++;
}
else if (t.Action == TrackActionKind.Keep)
{
sKeep++;
}
}
}
}
return new ConversionPlanActionStats(add, remove, cAudio, cVideo, sRem, sConv, sKeep);
}
}

View File

@ -0,0 +1,656 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
namespace EmbyToolbox.Models;
public sealed class ConversionQueueItem : INotifyPropertyChanged
{
private string _fullPath;
private string _fileName;
private string _directoryPath;
private int _orderNumber;
private int _progress;
private string _status = ConversionQueueStatus.Pending;
private string _profile = "Emby";
private string _planSummary = string.Empty;
private int _fileSizeMb;
/// <summary>True после успешного ffprobe (аудио-поля валидны для отображения).</summary>
private bool _ffprobeAnalyzed;
private int _ffprobeAudioCount;
private int? _ffprobeAudioSizeMb;
private bool _ffprobeAudioSizePartial;
private MediaAnalysisResult? _mediaAnalysis;
private IReadOnlyList<SidecarFile> _sidecars = System.Array.Empty<SidecarFile>();
private IReadOnlyList<ExternalAudioFile> _externalAudioFiles = System.Array.Empty<ExternalAudioFile>();
private ConversionPlan? _lastPlan;
private bool _isProcessed;
private bool _processedInCurrentRun;
private string? _lastRunId;
private bool _isSkipPlan = true;
private bool _isManuallyEdited;
private string? _errorMessage;
private string? _errorDetails;
public string FullPath
{
get => _fullPath;
private set
{
if (string.Equals(_fullPath, value, StringComparison.OrdinalIgnoreCase))
{
return;
}
_fullPath = value;
_directoryPath = Path.GetDirectoryName(value) ?? string.Empty;
OnPropertyChanged();
OnPropertyChanged(nameof(DirectoryPath));
}
}
public string FileName
{
get => _fileName;
private set
{
if (string.Equals(_fileName, value, StringComparison.Ordinal))
{
return;
}
_fileName = value;
OnPropertyChanged();
}
}
public string DirectoryPath
{
get => _directoryPath;
private set
{
if (string.Equals(_directoryPath, value, StringComparison.Ordinal))
{
return;
}
_directoryPath = value;
OnPropertyChanged();
}
}
/// <summary>Параметры из ffprobe + списки потоков.</summary>
public MediaAnalysisResult? MediaAnalysis
{
get => _mediaAnalysis;
private set
{
if (ReferenceEquals(_mediaAnalysis, value))
{
return;
}
_mediaAnalysis = value;
OnPropertyChanged();
OnPropertyChanged(nameof(TrackSummaryDisplay));
}
}
public IReadOnlyList<SidecarFile> Sidecars
{
get => _sidecars;
private set
{
if (Equals(_sidecars, value))
{
return;
}
_sidecars = value;
OnPropertyChanged();
}
}
/// <summary>Общий корень батча (добавление каталогом или вычисленный LCA файлов при перетаскивании/мультовыборе). Для области snapshot между эпизодами одного добавления.</summary>
public string? SnapshotScopeBatchRoot { get; set; }
/// <summary>Разбор внешних аудиофайлов (мультипотоковые контейнеры и т.д.) для пере-seed дорожек.</summary>
public IReadOnlyList<ExternalAudioFile> ExternalAudioFiles
{
get => _externalAudioFiles;
private set
{
if (Equals(_externalAudioFiles, value))
{
return;
}
_externalAudioFiles = value;
OnPropertyChanged();
}
}
public ConversionTaskOverride TaskOverride { get; } = new();
public ConversionPlan? LastPlan
{
get => _lastPlan;
private set
{
if (Equals(_lastPlan, value))
{
return;
}
_lastPlan = value;
OnPropertyChanged();
}
}
public ConversionQueueItem(string fullPath)
{
var normalized = Path.GetFullPath(fullPath);
_fullPath = normalized;
_fileName = Path.GetFileName(normalized);
_directoryPath = Path.GetDirectoryName(normalized) ?? string.Empty;
SetInitialFileSizeBytes();
}
/// <summary>Размер файла, МБ (целое, округление).</summary>
public int FileSizeMb
{
get => _fileSizeMb;
private set
{
if (_fileSizeMb == value)
{
return;
}
_fileSizeMb = value;
OnPropertyChanged();
}
}
private void SetInitialFileSizeBytes()
{
try
{
if (File.Exists(FullPath))
{
var len = new FileInfo(FullPath).Length;
FileSizeMb = (int)Math.Round(len / 1024.0 / 1024.0, MidpointRounding.AwayFromZero);
}
}
catch
{
FileSizeMb = 0;
}
}
public void RefreshFileSizeFromDisk() => SetInitialFileSizeBytes();
/// <summary>Восстановление медиаданных из снимка (загрузка очереди) без затрагивания <see cref="IsManuallyEdited"/>.</summary>
public void RestorePersistedMediaSnapshot(
MediaAnalysisResult media,
IReadOnlyList<SidecarFile> sidecars,
IReadOnlyList<ExternalAudioFile> externalAudioFiles,
bool hasFfprobeAudioSummary,
int ffprobeAudioCount,
int? ffprobeAudioSizeMb,
bool ffprobeAudioSizePartial)
{
MediaAnalysis = media;
Sidecars = sidecars;
ExternalAudioFiles = externalAudioFiles;
if (hasFfprobeAudioSummary)
{
SetFfprobeAudioData(ffprobeAudioCount, ffprobeAudioSizeMb, ffprobeAudioSizePartial);
}
else
{
_ffprobeAnalyzed = false;
_ffprobeAudioCount = 0;
_ffprobeAudioSizeMb = null;
_ffprobeAudioSizePartial = false;
OnPropertyChanged(nameof(AudioTracksDisplay));
OnPropertyChanged(nameof(AudioTracksSortValue));
OnPropertyChanged(nameof(AudioSizeMbDisplay));
OnPropertyChanged(nameof(AudioSizeSortValue));
OnPropertyChanged(nameof(AudioSizeMbToolTip));
OnPropertyChanged(nameof(HasFfprobeAudioSummary));
}
}
/// <summary>Есть ли сохранённые краткие данные ffprobe по аудио (для .conv_setup).</summary>
public bool HasFfprobeAudioSummary => _ffprobeAnalyzed;
/// <summary>Число аудиопотоков из последнего анализа (0, если не анализировалось).</summary>
public int FfprobeEmbeddedAudioStreamCount => _ffprobeAnalyzed ? _ffprobeAudioCount : 0;
public int? FfprobeAudioSizeEstimateMb => _ffprobeAudioSizeMb;
public bool FfprobeAudioSizeEstimatePartial => _ffprobeAudioSizePartial;
public string AudioTracksDisplay => !_ffprobeAnalyzed ? "-" : _ffprobeAudioCount.ToString();
public int AudioTracksSortValue => _ffprobeAnalyzed ? _ffprobeAudioCount : -1;
public string AudioSizeMbDisplay
{
get
{
if (!_ffprobeAnalyzed)
{
return "-";
}
if (_ffprobeAudioSizeMb is not int audioMb)
{
return "-";
}
return _ffprobeAudioSizePartial ? $"{audioMb}*" : audioMb.ToString();
}
}
public string TrackSummaryDisplay
{
get
{
if (MediaAnalysis is null)
{
return "-";
}
var videoTotal = MediaAnalysis.VideoStreams.Count;
var audioTotal = MediaAnalysis.AudioStreams.Count;
var subtitleTotal = MediaAnalysis.SubtitleStreams.Count;
var attachmentTotal = MediaAnalysis.AllStreams.Count(s => s.Kind == MediaStreamKind.Attachment);
var overrides = TaskOverride.TrackOverrides;
if (overrides.Count > 0)
{
foreach (var track in overrides)
{
if (track.StreamKind == MediaStreamKind.Video)
{
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
{
videoTotal--;
}
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
{
videoTotal++;
}
}
else if (track.StreamKind == MediaStreamKind.Audio)
{
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
{
audioTotal--;
}
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
{
audioTotal++;
}
}
else if (track.StreamKind == MediaStreamKind.Subtitle)
{
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
{
subtitleTotal--;
}
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
{
subtitleTotal++;
}
}
else if (track.StreamKind == MediaStreamKind.Attachment)
{
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
{
attachmentTotal--;
}
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
{
attachmentTotal++;
}
}
}
}
if (videoTotal < 0)
{
videoTotal = 0;
}
if (audioTotal < 0)
{
audioTotal = 0;
}
if (subtitleTotal < 0)
{
subtitleTotal = 0;
}
if (attachmentTotal < 0)
{
attachmentTotal = 0;
}
return $"🎬 {videoTotal} 🔊 {audioTotal} 💬 {subtitleTotal} 📎 {attachmentTotal}";
}
}
public int AudioSizeSortValue => _ffprobeAnalyzed && _ffprobeAudioSizeMb is { } mb ? mb : -1;
/// <summary>Подсказка для «размер аудио» с пометкой * о частичном расчёте.</summary>
public string? AudioSizeMbToolTip
{
get
{
if (_ffprobeAnalyzed && _ffprobeAudioSizePartial)
{
return "* расчет частичный, у части дорожек неизвестен bitrate";
}
if (_ffprobeAnalyzed && _ffprobeAudioSizeMb is not null && _ffprobeAudioSizeMb >= 0)
{
return "Суммарная оценка: длительность × битрейт по дорожкам, МБ (целое).";
}
return null;
}
}
public void SetFfprobeAudioData(int trackCount, int? audioSizeMb, bool isPartial)
{
_ffprobeAnalyzed = true;
_ffprobeAudioCount = trackCount;
_ffprobeAudioSizeMb = audioSizeMb;
_ffprobeAudioSizePartial = isPartial;
OnPropertyChanged(nameof(AudioTracksDisplay));
OnPropertyChanged(nameof(AudioTracksSortValue));
OnPropertyChanged(nameof(AudioSizeMbDisplay));
OnPropertyChanged(nameof(AudioSizeSortValue));
OnPropertyChanged(nameof(AudioSizeMbToolTip));
OnPropertyChanged(nameof(HasFfprobeAudioSummary));
}
public void SetSuccessfulMediaAnalysis(
MediaAnalysisResult media,
IReadOnlyList<SidecarFile> sidecars,
IReadOnlyList<ExternalAudioFile> externalAudioFiles,
ConversionPlan plan,
int audioCount,
int? audioSizeMb,
bool audioSizePartial)
{
MediaAnalysis = media;
Sidecars = sidecars;
ExternalAudioFiles = externalAudioFiles;
LastPlan = plan;
PlanSummary = string.IsNullOrWhiteSpace(plan.ShortSummary) ? "Skip — обработка не требуется" : plan.ShortSummary;
IsManuallyEdited = false;
RecomputeSkipFlag();
SetFfprobeAudioData(audioCount, audioSizeMb, audioSizePartial);
}
public void SetPlan(ConversionPlan plan)
{
LastPlan = plan;
PlanSummary = string.IsNullOrWhiteSpace(plan.ShortSummary) ? "Skip — обработка не требуется" : plan.ShortSummary;
RecomputeSkipFlag();
OnPropertyChanged(nameof(TrackSummaryDisplay));
}
public int OrderNumber
{
get => _orderNumber;
set
{
if (_orderNumber == value)
{
return;
}
_orderNumber = value;
OnPropertyChanged();
OnPropertyChanged(nameof(DisplayIndexText));
}
}
public string Status
{
get => _status;
set
{
if (_status == value)
{
return;
}
_status = value;
OnPropertyChanged();
OnPropertyChanged(nameof(StatusSortOrder));
OnPropertyChanged(nameof(DisplayProgressPercent));
}
}
public int StatusSortOrder => _status switch
{
ConversionQueueStatus.Pending => 0,
ConversionQueueStatus.Running => 1,
ConversionQueueStatus.Copying => 2,
ConversionQueueStatus.Replacing => 3,
ConversionQueueStatus.Done => 4,
ConversionQueueStatus.Error => 5,
ConversionQueueStatus.Cancelled => 6,
_ => 99
};
/// <summary>Сырое значение 0100 для пайплайна (ffmpeg/копирование); в UI использовать <see cref="DisplayProgressPercent"/>.</summary>
public int Progress
{
get => _progress;
set
{
if (_progress == value)
{
return;
}
_progress = value;
OnPropertyChanged();
OnPropertyChanged(nameof(DisplayProgressPercent));
}
}
/// <summary>Прогресс для интерфейса: 100% только при статусе «Готово»; иначе не выше 99.</summary>
public int DisplayProgressPercent =>
string.Equals(_status, ConversionQueueStatus.Done, StringComparison.Ordinal)
? 100
: Math.Min(99, Math.Max(0, _progress));
public string Profile
{
get => _profile;
set
{
if (_profile == value)
{
return;
}
_profile = value;
OnPropertyChanged();
}
}
public string PlanSummary
{
get => _planSummary;
set
{
if (_planSummary == value)
{
return;
}
_planSummary = value;
OnPropertyChanged();
}
}
public bool IsSkipPlan
{
get => _isSkipPlan;
set
{
if (_isSkipPlan == value)
{
return;
}
_isSkipPlan = value;
OnPropertyChanged();
}
}
public bool IsManuallyEdited
{
get => _isManuallyEdited;
set
{
if (_isManuallyEdited == value)
{
return;
}
_isManuallyEdited = value;
OnPropertyChanged();
OnPropertyChanged(nameof(DisplayIndexText));
OnPropertyChanged(nameof(ManualEditToolTip));
RecomputeSkipFlag();
}
}
public string DisplayIndexText => IsManuallyEdited ? $"✎ {OrderNumber}" : OrderNumber.ToString();
public string? ManualEditToolTip => IsManuallyEdited ? "Настройки изменены вручную" : null;
public bool IsProcessed
{
get => _isProcessed;
set
{
if (_isProcessed == value)
{
return;
}
_isProcessed = value;
OnPropertyChanged();
}
}
public bool ProcessedInCurrentRun
{
get => _processedInCurrentRun;
set
{
if (_processedInCurrentRun == value)
{
return;
}
_processedInCurrentRun = value;
OnPropertyChanged();
}
}
public string? LastRunId
{
get => _lastRunId;
set
{
if (_lastRunId == value)
{
return;
}
_lastRunId = value;
OnPropertyChanged();
}
}
public string? ErrorMessage
{
get => _errorMessage;
set
{
if (_errorMessage == value)
{
return;
}
_errorMessage = value;
OnPropertyChanged();
}
}
/// <summary>Подробный текст ошибки (stderr ffmpeg/ffprobe и т.д.); для буфера приоритетнее краткого <see cref="ErrorMessage"/>.</summary>
public string? ErrorDetails
{
get => _errorDetails;
set
{
if (_errorDetails == value)
{
return;
}
_errorDetails = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void RecomputeSkipFlag()
{
IsSkipPlan = LastPlan?.HasRealActions is false;
}
public void UpdateOutputPath(string newPath, string? newContainerFormat)
{
var normalized = Path.GetFullPath(newPath);
FullPath = normalized;
FileName = Path.GetFileName(normalized);
DirectoryPath = Path.GetDirectoryName(normalized) ?? string.Empty;
SetInitialFileSizeBytes();
if (MediaAnalysis is { } m)
{
MediaAnalysis = new MediaAnalysisResult
{
ContainerFormat = newContainerFormat ?? m.ContainerFormat,
FormatName = m.FormatName,
FormatBitRateBps = m.FormatBitRateBps,
DurationSeconds = m.DurationSeconds,
VideoStreams = m.VideoStreams,
AudioStreams = m.AudioStreams,
SubtitleStreams = m.SubtitleStreams,
DataStreams = m.DataStreams,
AllStreams = m.AllStreams,
SourceVideoBitrateBps = m.SourceVideoBitrateBps
};
OnPropertyChanged(nameof(TrackSummaryDisplay));
}
}
}

View File

@ -0,0 +1,38 @@
using System.Collections;
namespace EmbyToolbox.Models;
/// <summary>Правила контекстного меню «Копировать ошибку» и состава текста буфера обмена.</summary>
public static class ConversionQueueItemErrorCopy
{
public static bool IsEligibleItem(ConversionQueueItem? item)
{
return item is not null
&& string.Equals(item.Status, ConversionQueueStatus.Error, StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(item.ErrorMessage);
}
public static bool ShouldShowForSelection(IList? selected)
{
if (selected is not { Count: 1 })
{
return false;
}
return IsEligibleItem(selected[0] as ConversionQueueItem);
}
/// <summary>
/// Если есть подробный вывод (ffmpeg stderr, ffprobe stderr и т.д.) — только он; иначе краткий <see cref="ConversionQueueItem.ErrorMessage"/>.
/// </summary>
public static string GetClipboardText(ConversionQueueItem item)
{
var detail = item.ErrorDetails?.Trim();
if (!string.IsNullOrEmpty(detail))
{
return detail;
}
return item.ErrorMessage?.Trim() ?? string.Empty;
}
}

View File

@ -0,0 +1,15 @@
namespace EmbyToolbox.Models;
public static class ConversionQueueStatus
{
public const string Pending = "В очереди";
public const string Analyzing = "Анализ";
public const string Ready = "Готов";
public const string Running = "В работе";
public const string Copying = "Копирование";
public const string Replacing = "Замена";
public const string Done = "Готово";
public const string Skipped = "Пропуск";
public const string Error = "Ошибка";
public const string Cancelled = "Отмена";
}

View File

@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
namespace EmbyToolbox.Models;
/// <summary>Переопределения пользователя: видео-параметры и дорожки.</summary>
public sealed class ConversionTaskOverride
{
public string TargetContainer { get; set; } = string.Empty;
public string TargetVideo { get; set; } = string.Empty;
public string TargetPixelFormat { get; set; } = string.Empty;
public string TargetResolution { get; set; } = string.Empty;
public string TargetFps { get; set; } = string.Empty;
public string TargetAudioBitrate { get; set; } = "256 kbps";
public string TargetVideoBitrateMode { get; set; } = "Auto";
public double? TargetVideoBitrateMbps { get; set; }
public List<TrackOverrideEntry> TrackOverrides { get; } = new();
public void CopyFrom(ConversionTaskOverride o)
{
TargetContainer = o.TargetContainer;
TargetVideo = o.TargetVideo;
TargetPixelFormat = o.TargetPixelFormat;
TargetResolution = o.TargetResolution;
TargetFps = o.TargetFps;
TargetAudioBitrate = o.TargetAudioBitrate;
TargetVideoBitrateMode = o.TargetVideoBitrateMode;
TargetVideoBitrateMbps = o.TargetVideoBitrateMbps;
TrackOverrides.Clear();
foreach (var t in o.TrackOverrides)
{
TrackOverrides.Add(t.Clone());
}
}
public ConversionTaskOverride Clone()
{
var c = new ConversionTaskOverride();
c.CopyFrom(this);
return c;
}
}
public sealed class TrackOverrideEntry
{
/// <summary>Stream index (ffprobe) для встроенных; отрицательный — внешняя дорожка, см. <see cref="ExternalPath"/>.</summary>
public int StreamIndex { get; init; }
public string? ExternalPath { get; init; }
public SourceKind Source { get; init; }
public MediaStreamKind StreamKind { get; init; }
public TrackActionKind Action { get; set; } = TrackActionKind.Keep;
public bool? Default { get; set; }
public string? Language { get; set; }
public string? Title { get; set; }
public string? AudioBitrateKbps { get; set; }
/// <summary>Для внешнего аудио: порядковый номер потока внутри файла (0…) для ffmpeg <c>-map</c> <i>input:a:N</i>.</summary>
public int ExternalAudioStreamOrdinal { get; set; }
/// <summary>Кодек потока (ffprobe) для отображения / snapshot при внешнем аудио.</summary>
public string? ExternalStreamCodec { get; set; }
/// <summary>Сводка для столбца Details у внешних дорожек.</summary>
public string? ExternalStreamDetails { get; set; }
/// <summary>Число аудиопотоков в том же внешнем файле (для заголовка по умолчанию / snapshot).</summary>
public int SameFileExternalAudioStreamCount { get; set; } = 1;
/// <summary>При внешнем аудио: title из ffprobe-тегов, если был; иначе <see langword="null"/> (заголовок сгенерирован из имени файла).</summary>
public string? ExternalFfprobeTitle { get; set; }
public TrackOverrideEntry Clone() =>
new()
{
StreamIndex = StreamIndex,
ExternalPath = ExternalPath,
Source = Source,
StreamKind = StreamKind,
Action = Action,
Default = Default,
Language = Language,
Title = Title,
AudioBitrateKbps = AudioBitrateKbps,
ExternalAudioStreamOrdinal = ExternalAudioStreamOrdinal,
ExternalStreamCodec = ExternalStreamCodec,
ExternalStreamDetails = ExternalStreamDetails,
SameFileExternalAudioStreamCount = SameFileExternalAudioStreamCount,
ExternalFfprobeTitle = ExternalFfprobeTitle
};
}

View File

@ -0,0 +1,11 @@
namespace EmbyToolbox.Models;
/// <summary>Краткое описание плана по одной логической дорожке/шагу.</summary>
public sealed class ConversionTrackPlan
{
public int? StreamIndex { get; init; }
public SourceKind? Source { get; init; }
public MediaStreamKind StreamKind { get; init; }
public ConversionPlanAction Action { get; init; }
public string Description { get; init; } = string.Empty;
}

View File

@ -0,0 +1,44 @@
namespace EmbyToolbox.Models;
/// <summary>Сторонний аудиофайл (контейнер или raw), возможно с несколькими аудиопотоками.</summary>
public sealed class ExternalAudioFile
{
public ExternalAudioFile(string fullPath, IReadOnlyList<ExternalAudioStream> streams)
{
FullPath = fullPath;
Streams = streams;
}
public string FullPath { get; }
public IReadOnlyList<ExternalAudioStream> Streams { get; }
}
/// <summary>Один аудиопоток внутри <see cref="ExternalAudioFile"/> (индекс для ffmpeg <c>-map</c> <i>input:a:N</i>).</summary>
public sealed class ExternalAudioStream
{
public required string FileFullPath { get; init; }
/// <summary>Порядковый номер аудиопотока внутри файла (0 = первый audio), для <c>-map M:a:N</c>.</summary>
public int StreamOrdinal { get; init; }
public required string CodecName { get; init; }
public string? TitleFromProbe { get; init; }
public int? Channels { get; init; }
public int? SampleRateHz { get; init; }
public long? BitRateBps { get; init; }
}
/// <summary>Результат поиска sidecar: объединённые файлы для плана/ffmpeg и разобранное внешнее аудио.</summary>
public sealed class SidecarDiscoveryResult
{
public SidecarDiscoveryResult(IReadOnlyList<SidecarFile> sidecars, IReadOnlyList<ExternalAudioFile> externalAudioFiles)
{
Sidecars = sidecars;
ExternalAudioFiles = externalAudioFiles;
}
public IReadOnlyList<SidecarFile> Sidecars { get; }
/// <summary>Разбор аудиофайлов (пути совпадают с audio-<see cref="SidecarFile"/>).</summary>
public IReadOnlyList<ExternalAudioFile> ExternalAudioFiles { get; }
}

View File

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace EmbyToolbox.Models;
/// <summary>Результат детального ffprobe.</summary>
public sealed class MediaAnalysisResult
{
public string? ContainerFormat { get; init; }
public string? FormatName { get; init; }
public long? FormatBitRateBps { get; init; }
public double? DurationSeconds { get; init; }
public IReadOnlyList<MediaStreamInfo> VideoStreams { get; init; } = Array.Empty<MediaStreamInfo>();
public IReadOnlyList<MediaStreamInfo> AudioStreams { get; init; } = Array.Empty<MediaStreamInfo>();
public IReadOnlyList<MediaStreamInfo> SubtitleStreams { get; init; } = Array.Empty<MediaStreamInfo>();
public IReadOnlyList<MediaStreamInfo> DataStreams { get; init; } = Array.Empty<MediaStreamInfo>();
public IReadOnlyList<MediaStreamInfo> AllStreams { get; init; } = Array.Empty<MediaStreamInfo>();
public long? SourceVideoBitrateBps { get; init; }
/// <summary>Основной видеопоток: самый крупный по площади кадра (не первый подряд в JSON — иначе cover/mjpeg может оказаться «основным»).</summary>
public MediaStreamInfo? PrimaryVideo =>
VideoStreams
.OrderByDescending(static v => ((long)(v.Width ?? 0)) * (v.Height ?? 0))
.ThenByDescending(static v => v.IsDefault ? 1 : 0)
.FirstOrDefault();
/// <summary>format.duration, иначе максимум duration среди streams (ffprobe).</summary>
public double? GetEffectiveDurationSeconds()
{
if (DurationSeconds is { } f && f > 0)
{
return f;
}
double? best = null;
foreach (var s in AllStreams)
{
if (s.DurationSeconds is { } d && d > 0)
{
best = best is { } b ? Math.Max(b, d) : d;
}
}
return best;
}
}

View File

@ -0,0 +1,41 @@
namespace EmbyToolbox.Models;
/// <summary>Одна встроенная дорожка из ffprobe (streams[]).</summary>
public sealed class MediaStreamInfo
{
public int Index { get; init; }
public MediaStreamKind Kind { get; init; }
public string CodecName { get; init; } = string.Empty;
public string? Language { get; init; }
public string? Title { get; init; }
public bool IsDefault { get; init; }
public long? BitRateBps { get; init; }
public string? Profile { get; init; }
public string? Encoder { get; init; }
public int? Channels { get; init; }
public int? SampleRateHz { get; init; }
public int? Width { get; init; }
public int? Height { get; init; }
public double? AverageFrameRate { get; init; }
public double? FrameRate { get; init; }
public string? PixelFormat { get; init; }
/// <summary>Цветметаданные из ffprobe (video): color_space, color_primaries, color_transfer.</summary>
public string? ColorSpace { get; init; }
public string? ColorPrimaries { get; init; }
public string? ColorTransfer { get; init; }
public string? SubtitleFormat { get; init; }
public string? FileNameTag { get; init; }
public bool IsForcedByDisposition { get; init; }
public bool IsForced { get; init; }
public string? ForcedDetectionReason { get; init; }
public int? SubtitleEventCount { get; init; }
public double? SubtitleCoverage { get; init; }
/// <summary>Длительность дорожки (сек) из ffprobe, если задана.</summary>
public double? DurationSeconds { get; init; }
/// <summary>Для потоков-вложений matroska: тег ffprobe tags.filename.</summary>
public string? AttachmentDeclaredFileName { get; init; }
/// <summary>Для потоков-вложений: tags.mimetype.</summary>
public string? AttachmentDeclaredMimeType { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace EmbyToolbox.Models;
/// <summary>Итог последней попытки объединения (для статуса и единого прогресса).</summary>
public enum MergeCompletionKind
{
None,
Success,
Cancelled,
Error
}

View File

@ -0,0 +1,86 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace EmbyToolbox.Models;
public sealed class MergeFileItem : INotifyPropertyChanged
{
private int _number;
private string _partName = string.Empty;
private string _status = "Готов";
private bool _partNameUserOverride;
public string FullPath { get; init; } = string.Empty;
public string FileName { get; init; } = string.Empty;
public int SizeMb { get; init; }
public int Number
{
get => _number;
set
{
if (_number == value)
{
return;
}
_number = value;
OnPropertyChanged();
}
}
public string PartName
{
get => _partName;
set
{
if (_partName == value)
{
return;
}
_partName = value;
_partNameUserOverride = true;
OnPropertyChanged();
}
}
/// <summary>Авто-подпись части при перестановке строк; не затирает имя, если пользователь правил вручную.</summary>
public void SyncAutoPartName(string value)
{
if (_partNameUserOverride)
{
return;
}
if (_partName == value)
{
return;
}
_partName = value;
OnPropertyChanged();
}
public string Status
{
get => _status;
set
{
if (_status == value)
{
return;
}
_status = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@ -0,0 +1,22 @@
using System;
namespace EmbyToolbox.Models;
/// <summary>Внешний sidecar-файл (аудио или субтитры) рядом с видео.</summary>
public sealed class SidecarFile
{
public string FullPath { get; }
public bool IsAudio { get; }
public bool IsSubtitle { get; }
public bool IsFont { get; }
public string FileName { get; }
public SidecarFile(string fullPath, bool isAudio, bool isSubtitle, bool isFont = false)
{
FullPath = fullPath;
FileName = System.IO.Path.GetFileName(fullPath);
IsAudio = isAudio;
IsSubtitle = isSubtitle;
IsFont = isFont;
}
}

View File

@ -0,0 +1,7 @@
namespace EmbyToolbox.Models;
public enum SourceKind
{
Embedded,
External
}

View File

@ -0,0 +1,10 @@
namespace EmbyToolbox.Models;
public enum MediaStreamKind
{
Video,
Audio,
Subtitle,
Attachment,
Data
}

View File

@ -0,0 +1,10 @@
namespace EmbyToolbox.Models;
/// <summary>Действие над дорожкой в плане и в окне настроек.</summary>
public enum TrackActionKind
{
Keep,
Convert,
Remove,
Add
}

View File

@ -0,0 +1,262 @@
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
namespace EmbyToolbox.Models;
public sealed class TrackExtractionQueueItem : INotifyPropertyChanged
{
private string _fullPath = string.Empty;
private string _fileName = string.Empty;
private int _rowNumber = 1;
private double _sizeMb;
private string _audioSummary = "\u2014";
private string _subtitleSummary = "\u2014";
private string _attachmentSummary = "\u2014";
private string _status = TrackExtractionStatuses.Queued;
private double _progressPercent;
private string _message = string.Empty;
private MediaAnalysisResult? _mediaAnalysis;
private int _totalTracksToExtract;
public TrackExtractionQueueItem(string fullPath)
{
RefreshPath(fullPath);
}
public string FullPath
{
get => _fullPath;
private set
{
if (_fullPath.Equals(value, StringComparison.OrdinalIgnoreCase))
{
return;
}
_fullPath = value;
OnPropertyChanged();
}
}
public string FileName
{
get => _fileName;
private set
{
if (_fileName == value)
{
return;
}
_fileName = value;
OnPropertyChanged();
}
}
public int RowNumber
{
get => _rowNumber;
internal set
{
if (_rowNumber == value)
{
return;
}
_rowNumber = value;
OnPropertyChanged();
}
}
public double SizeMb
{
get => _sizeMb;
private set
{
if (Math.Abs(_sizeMb - value) < 0.01)
{
return;
}
_sizeMb = value;
OnPropertyChanged();
}
}
public string AudioSummary
{
get => _audioSummary;
private set
{
if (_audioSummary == value)
{
return;
}
_audioSummary = value;
OnPropertyChanged();
}
}
public string SubtitleSummary
{
get => _subtitleSummary;
private set
{
if (_subtitleSummary == value)
{
return;
}
_subtitleSummary = value;
OnPropertyChanged();
}
}
public string AttachmentSummary
{
get => _attachmentSummary;
private set
{
if (_attachmentSummary == value)
{
return;
}
_attachmentSummary = value;
OnPropertyChanged();
}
}
public string Status
{
get => _status;
set
{
if (_status == value)
{
return;
}
_status = value;
OnPropertyChanged();
}
}
public double ProgressPercent
{
get => _progressPercent;
set
{
if (Math.Abs(_progressPercent - value) < 0.01)
{
return;
}
_progressPercent = value;
OnPropertyChanged();
}
}
public string Message
{
get => _message;
set
{
if (_message == value)
{
return;
}
_message = value;
OnPropertyChanged();
}
}
public MediaAnalysisResult? MediaAnalysis
{
get => _mediaAnalysis;
private set
{
if (ReferenceEquals(_mediaAnalysis, value))
{
return;
}
_mediaAnalysis = value;
OnPropertyChanged();
}
}
public int TotalTracksToExtract
{
get => _totalTracksToExtract;
private set
{
if (_totalTracksToExtract == value)
{
return;
}
_totalTracksToExtract = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
public void RefreshPath(string path)
{
FullPath = path;
FileName = Path.GetFileName(path);
try
{
var info = new FileInfo(path);
SizeMb = info.Exists ? Math.Round(info.Length / (1024.0 * 1024.0), 2) : 0;
}
catch
{
SizeMb = 0;
}
}
public void ResetForRequeue()
{
MediaAnalysis = null;
TotalTracksToExtract = 0;
const string dash = "\u2014";
AudioSummary = SubtitleSummary = AttachmentSummary = dash;
ProgressPercent = 0;
Message = string.Empty;
Status = TrackExtractionStatuses.Queued;
}
public void ApplyAnalysisOk(MediaAnalysisResult media)
{
MediaAnalysis = media;
var attachments = media.AllStreams.Count(x => x.Kind == MediaStreamKind.Attachment);
AudioSummary = media.AudioStreams.Count.ToString(CultureInfo.InvariantCulture);
SubtitleSummary = media.SubtitleStreams.Count.ToString(CultureInfo.InvariantCulture);
AttachmentSummary = attachments.ToString(CultureInfo.InvariantCulture);
TotalTracksToExtract = media.AudioStreams.Count + media.SubtitleStreams.Count + attachments;
Status = TrackExtractionStatuses.Ready;
ProgressPercent = 0;
Message = string.Empty;
}
public void ApplyAnalysisError(string reason)
{
MediaAnalysis = null;
TotalTracksToExtract = 0;
const string dash = "\u2014";
AudioSummary = SubtitleSummary = AttachmentSummary = dash;
Status = TrackExtractionStatuses.Error;
ProgressPercent = 0;
Message = reason.Trim();
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

View File

@ -0,0 +1,20 @@
namespace EmbyToolbox.Models;
public static class TrackExtractionStatuses
{
public const string Queued = "В очереди";
public const string Analyzing = "Анализ";
public const string Ready = "Готов";
public const string Working = "В работе";
public const string Done = "Готово";
public const string Error = "Ошибка";
public const string Cancelled = "Отмена";
}
public enum TrackExtractionRunOutcome
{
None,
Success,
Error,
Cancelled,
}

View File

@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace EmbyToolbox.Models;
public sealed class TrackSettingsSnapshot
{
public string FilePath { get; init; } = string.Empty;
/// <summary>Нормализованный абсолютный каталог исходного видеофайла (родитель имени).</summary>
public string ScopeDirectory { get; init; } = string.Empty;
/// <summary>При добавлении каталогом — корень выбранной папки; при перетаскивании — общий предок каталогов батча.</summary>
public string? ScopeBatchRoot { get; init; }
public TrackStructureSignature Signature { get; init; } = new();
public IReadOnlyList<TrackSettingsSnapshotItem> Items { get; init; } = [];
}
public sealed class TrackStructureSignature
{
public IReadOnlyList<TrackStructureSignatureItem> Tracks { get; init; } = [];
}
public sealed class TrackStructureSignatureItem
{
public int Order { get; init; }
public MediaStreamKind StreamKind { get; init; }
public SourceKind Source { get; init; }
public string Codec { get; init; } = string.Empty;
public string Language { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
}
public sealed class TrackSettingsSnapshotItem
{
public int Order { get; init; }
public MediaStreamKind StreamKind { get; init; }
public SourceKind Source { get; init; }
public string Codec { get; init; } = string.Empty;
public string Language { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public TrackActionKind Action { get; init; }
public string? Bitrate { get; init; }
public bool? Default { get; init; }
public string? TargetCodec { get; init; }
/// <summary>True, если Title в UI отличается от типового (ffprobe/filename) — тогда при apply копируем Title.</summary>
public bool SnapshotTitleWasUserEdited { get; init; }
}

View File

@ -0,0 +1,40 @@
namespace EmbyToolbox.Models;
/// <summary>Как сопоставлена дорожка текущего файла со snapshot.</summary>
public enum MatchingStrategy
{
None,
/// <summary>Type + Source + язык + порядковый номер внутри этой группы (как в текущем файле).</summary>
OrdinalByTypeSourceLanguage
}
/// <summary>Результат сопоставления одной дорожки текущего файла.</summary>
public sealed class TrackMatchResult
{
public int CurrentOrder { get; init; }
public bool IsMatched { get; init; }
public MatchingStrategy Strategy { get; init; }
public bool HadAmbiguousCandidates { get; init; }
public TrackSettingsSnapshotItem? SourceItem { get; init; }
public TrackActionKind ResolvedAction { get; init; }
}
public enum SnapshotApplyDegree
{
None,
Partial,
Full
}
public enum SnapshotApplyReason
{
NoSnapshot,
ScopeMismatch,
Success
}
public readonly record struct SnapshotApplyResult(
bool AppliedAny,
SnapshotApplyDegree Degree,
SnapshotApplyReason Reason,
IReadOnlyList<TrackMatchResult>? TrackResults);

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,386 @@
using System.Text.Json;
using System.IO;
using System.Linq;
namespace EmbyToolbox.Services;
public sealed class AppSettingsService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
private readonly string _settingsFilePath;
public AppSettingsService()
{
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EmbyToolbox");
_settingsFilePath = Path.Combine(appDataDir, "settings.json");
}
/// <summary>Нормализация списка профилей (в т.ч. после загрузки .conv_setup).</summary>
public static List<ConversionProfileSettingsEntry> NormalizeStoredConversionProfiles(
List<ConversionProfileSettingsEntry>? profiles) =>
NormalizeProfiles(profiles);
public AppSettings Load()
{
try
{
if (File.Exists(_settingsFilePath))
{
var json = File.ReadAllText(_settingsFilePath);
var loaded = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions);
if (loaded is not null)
{
loaded.ProcessingTempDirectory = NormalizeTempDirectory(loaded.ProcessingTempDirectory);
loaded.MinimumFileLogLevel = NormalizeLogLevel(loaded.MinimumFileLogLevel);
loaded.HardwareAcceleration = NormalizeHardwareAcceleration(loaded.HardwareAcceleration);
loaded.ConversionProfiles = NormalizeProfiles(loaded.ConversionProfiles);
EnsureDirectoryExists(loaded.ProcessingTempDirectory);
return loaded;
}
}
}
catch
{
// If settings are corrupted or unreadable, fall back to defaults.
}
var defaults = CreateDefaults();
EnsureDirectoryExists(defaults.ProcessingTempDirectory);
return defaults;
}
public void Save(AppSettings settings)
{
var sanitized = new AppSettings
{
ProcessingTempDirectory = NormalizeTempDirectory(settings.ProcessingTempDirectory),
MinimumFileLogLevel = NormalizeLogLevel(settings.MinimumFileLogLevel),
HardwareAcceleration = NormalizeHardwareAcceleration(settings.HardwareAcceleration),
ConversionProfiles = NormalizeProfiles(settings.ConversionProfiles),
IsLogCollapsed = settings.IsLogCollapsed,
NotifyCompletionSoundAfterQueue = settings.NotifyCompletionSoundAfterQueue,
NotifyWindowsToastAfterQueue = settings.NotifyWindowsToastAfterQueue,
LastSeriesRenamerFolder = settings.LastSeriesRenamerFolder,
LastConversionFilesFolder = settings.LastConversionFilesFolder,
LastConversionFolder = settings.LastConversionFolder,
LastMergeFolder = settings.LastMergeFolder,
LastVideoInfoFolder = settings.LastVideoInfoFolder,
LastTempFolder = settings.LastTempFolder,
LastOutputFolder = settings.LastOutputFolder,
LastTrackExtractDestinationFolder = settings.LastTrackExtractDestinationFolder,
LastCommonFolder = settings.LastCommonFolder,
CopyPreviousTrackSettings = settings.CopyPreviousTrackSettings,
DisableSubtitleDefault = settings.DisableSubtitleDefault
};
var settingsDir = Path.GetDirectoryName(_settingsFilePath)!;
Directory.CreateDirectory(settingsDir);
EnsureDirectoryExists(sanitized.ProcessingTempDirectory);
var json = JsonSerializer.Serialize(sanitized, JsonOptions);
File.WriteAllText(_settingsFilePath, json);
}
private static AppSettings CreateDefaults()
{
return new AppSettings
{
ProcessingTempDirectory = NormalizeTempDirectory(null),
MinimumFileLogLevel = LogLevel.Info.ToString(),
HardwareAcceleration = HardwareAccelerationMode.Auto,
IsLogCollapsed = true,
CopyPreviousTrackSettings = false,
DisableSubtitleDefault = false,
NotifyCompletionSoundAfterQueue = true,
NotifyWindowsToastAfterQueue = true,
ConversionProfiles = CreateDefaultProfiles()
};
}
private static List<ConversionProfileSettingsEntry> NormalizeProfiles(List<ConversionProfileSettingsEntry>? profiles)
{
var defaults = CreateDefaultProfiles();
var defaultByName = defaults.ToDictionary(p => p.Profile, StringComparer.OrdinalIgnoreCase);
var mergedBuiltIn = new Dictionary<string, ConversionProfileSettingsEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var name in new[] { "Emby", "Web", "Archive" })
{
mergedBuiltIn[name] = CloneEntry(defaultByName[name]);
}
var customByName = new Dictionary<string, ConversionProfileSettingsEntry>(StringComparer.OrdinalIgnoreCase);
if (profiles is not null)
{
foreach (var p in profiles)
{
if (string.IsNullOrWhiteSpace(p.Profile))
{
continue;
}
var name = p.Profile.Trim();
if (IsBuiltInProfileName(name))
{
var def = defaultByName[name];
mergedBuiltIn[name] = MergeEntry(def, p);
}
else
{
customByName[name] = SanitizeCustomEntry(p);
}
}
}
var result = new List<ConversionProfileSettingsEntry>();
foreach (var name in new[] { "Emby", "Web", "Archive" })
{
result.Add(mergedBuiltIn[name]);
}
result.AddRange(customByName.Values.OrderBy(p => p.Profile, StringComparer.CurrentCultureIgnoreCase));
return result;
}
private static bool IsBuiltInProfileName(string name)
{
return name.Equals("Emby", StringComparison.OrdinalIgnoreCase)
|| name.Equals("Web", StringComparison.OrdinalIgnoreCase)
|| name.Equals("Archive", StringComparison.OrdinalIgnoreCase);
}
private static ConversionProfileSettingsEntry CloneEntry(ConversionProfileSettingsEntry source)
{
return new ConversionProfileSettingsEntry
{
Profile = source.Profile,
Container = source.Container,
Video = source.Video,
PixelFormat = source.PixelFormat,
Resolution = source.Resolution,
Fps = source.Fps,
Audio = source.Audio,
Bitrate = source.Bitrate,
VideoBitrateMode = source.VideoBitrateMode,
VideoBitrateMbps = source.VideoBitrateMbps,
Subtitles = source.Subtitles,
ExternalTracks = source.ExternalTracks,
ExternalSubtitles = source.ExternalSubtitles,
Fonts = source.Fonts
};
}
private static ConversionProfileSettingsEntry MergeEntry(ConversionProfileSettingsEntry def, ConversionProfileSettingsEntry fromFile)
{
return new ConversionProfileSettingsEntry
{
Profile = def.Profile,
Container = CoalesceField(fromFile.Container, def.Container),
Video = CoalesceField(fromFile.Video, def.Video),
PixelFormat = CoalesceField(fromFile.PixelFormat, def.PixelFormat),
Resolution = CoalesceField(fromFile.Resolution, def.Resolution),
Fps = CoalesceField(fromFile.Fps, def.Fps),
Audio = CoalesceField(fromFile.Audio, def.Audio),
Bitrate = CoalesceField(fromFile.Bitrate, def.Bitrate),
VideoBitrateMode = CoalesceField(fromFile.VideoBitrateMode, def.VideoBitrateMode),
VideoBitrateMbps = fromFile.VideoBitrateMbps > 0 ? fromFile.VideoBitrateMbps : def.VideoBitrateMbps,
Subtitles = CoalesceField(fromFile.Subtitles, def.Subtitles),
ExternalTracks = CoalesceField(fromFile.ExternalTracks, def.ExternalTracks),
ExternalSubtitles = CoalesceField(fromFile.ExternalSubtitles, def.ExternalSubtitles),
Fonts = CoalesceField(fromFile.Fonts, def.Fonts)
};
}
private static string CoalesceField(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private static ConversionProfileSettingsEntry SanitizeCustomEntry(ConversionProfileSettingsEntry p)
{
return new ConversionProfileSettingsEntry
{
Profile = p.Profile.Trim(),
Container = p.Container?.Trim() ?? "MKV",
Video = p.Video?.Trim() ?? "H.264",
PixelFormat = p.PixelFormat?.Trim() ?? "yuv420p",
Resolution = p.Resolution?.Trim() ?? "Без изменений",
Fps = p.Fps?.Trim() ?? "Без изменений",
Audio = p.Audio?.Trim() ?? "AAC",
Bitrate = p.Bitrate?.Trim() ?? "256 kbps",
VideoBitrateMode = string.IsNullOrWhiteSpace(p.VideoBitrateMode) ? VideoBitratePolicy.Auto : p.VideoBitrateMode.Trim(),
VideoBitrateMbps = p.VideoBitrateMbps > 0 ? p.VideoBitrateMbps : null,
Subtitles = p.Subtitles?.Trim() ?? "Да",
ExternalTracks = p.ExternalTracks?.Trim() ?? "Да",
ExternalSubtitles = p.ExternalSubtitles?.Trim() ?? "Да",
Fonts = p.Fonts?.Trim() ?? "Нет"
};
}
private static List<ConversionProfileSettingsEntry> CreateDefaultProfiles()
{
return
[
new()
{
Profile = "Emby",
Container = "MKV",
Video = "H.264",
PixelFormat = "yuv420p",
Resolution = "Без изменений",
Fps = "Без изменений",
Audio = "AAC",
Bitrate = "256 kbps",
VideoBitrateMode = VideoBitratePolicy.Auto,
Subtitles = "Да",
ExternalTracks = "Да",
ExternalSubtitles = "Да",
Fonts = "Да"
},
new()
{
Profile = "Web",
Container = "MP4",
Video = "H.264",
PixelFormat = "yuv420p",
Resolution = "Без изменений",
Fps = "Без изменений",
Audio = "AAC",
Bitrate = "256 kbps",
VideoBitrateMode = "8 Mbps",
Subtitles = "Да",
ExternalTracks = "Да",
ExternalSubtitles = "Да",
Fonts = "Нет"
},
new()
{
Profile = "Archive",
Container = "MP4",
Video = "H.264",
PixelFormat = "yuv420p",
Resolution = "Максимум 1080p",
Fps = "Максимум 30",
Audio = "AAC",
Bitrate = "256 kbps",
VideoBitrateMode = "4 Mbps",
Subtitles = "Да",
ExternalTracks = "Да",
ExternalSubtitles = "Да",
Fonts = "Нет"
}
];
}
private static string NormalizeTempDirectory(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Path.Combine(Path.GetTempPath(), "EmbyToolbox");
}
return value.Trim();
}
private static void EnsureDirectoryExists(string path)
{
if (!string.IsNullOrWhiteSpace(path))
{
Directory.CreateDirectory(path);
}
}
private static string NormalizeLogLevel(string? value)
{
if (Enum.TryParse<LogLevel>(value, ignoreCase: true, out var level))
{
return level.ToString();
}
return LogLevel.Info.ToString();
}
private static string NormalizeHardwareAcceleration(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return HardwareAccelerationMode.Auto;
}
var normalized = value.Trim();
return normalized switch
{
HardwareAccelerationMode.Auto => HardwareAccelerationMode.Auto,
HardwareAccelerationMode.Nvenc => HardwareAccelerationMode.Nvenc,
HardwareAccelerationMode.Qsv => HardwareAccelerationMode.Qsv,
HardwareAccelerationMode.Amf => HardwareAccelerationMode.Amf,
HardwareAccelerationMode.Cpu => HardwareAccelerationMode.Cpu,
_ => HardwareAccelerationMode.Auto
};
}
}
public sealed record AppSettings
{
public string ProcessingTempDirectory { get; set; } = Path.Combine(Path.GetTempPath(), "EmbyToolbox");
public string MinimumFileLogLevel { get; set; } = LogLevel.Info.ToString();
public string HardwareAcceleration { get; set; } = HardwareAccelerationMode.Auto;
public bool IsLogCollapsed { get; set; } = true;
public List<ConversionProfileSettingsEntry> ConversionProfiles { get; set; } = [];
public string? LastSeriesRenamerFolder { get; set; }
public string? LastConversionFilesFolder { get; set; }
public string? LastConversionFolder { get; set; }
public string? LastMergeFolder { get; set; }
public string? LastVideoInfoFolder { get; set; }
public string? LastTempFolder { get; set; }
public string? LastOutputFolder { get; set; }
public string? LastTrackExtractDestinationFolder { get; set; }
public string? LastCommonFolder { get; set; }
/// <summary>Конвертация: применять сохранённый snapshot дорожек с предыдущего настроенного файла.</summary>
public bool CopyPreviousTrackSettings { get; set; }
/// <summary>Конвертация: выключать default у всех subtitle-дорожек.</summary>
public bool DisableSubtitleDefault { get; set; }
/// <summary>После завершения очереди конвертации воспроизводить системный звук Windows.</summary>
public bool NotifyCompletionSoundAfterQueue { get; set; } = true;
/// <summary>После завершения очереди конвертации показывать уведомление Windows.</summary>
public bool NotifyWindowsToastAfterQueue { get; set; } = true;
}
public static class HardwareAccelerationMode
{
public const string Auto = "Auto";
public const string Nvenc = "NVENC";
public const string Qsv = "QSV";
public const string Amf = "AMF";
public const string Cpu = "CPU";
}
public sealed record ConversionProfileSettingsEntry
{
public string Profile { get; set; } = string.Empty;
public string Container { get; set; } = "MKV";
public string Video { get; set; } = "H.264";
public string PixelFormat { get; set; } = "yuv420p";
public string Resolution { get; set; } = "Без изменений";
public string Fps { get; set; } = "Без изменений";
public string Audio { get; set; } = "AAC";
public string Bitrate { get; set; } = "256 kbps";
public string VideoBitrateMode { get; set; } = VideoBitratePolicy.Auto;
public double? VideoBitrateMbps { get; set; }
public string Subtitles { get; set; } = "Да";
public string ExternalTracks { get; set; } = "Да";
public string ExternalSubtitles { get; set; } = "Да";
public string Fonts { get; set; } = "Нет";
}

View File

@ -0,0 +1,74 @@
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class BulkTrackSettingsService
{
private readonly TrackStructureComparer _comparer = new();
public BulkTrackSelectionAnalysis Analyze(IReadOnlyList<ConversionQueueItem> selected)
{
var candidates = selected
.Where(i => i.MediaAnalysis is not null && i.TaskOverride.TrackOverrides.Count > 0)
.ToList();
if (candidates.Count < 2)
{
return BulkTrackSelectionAnalysis.Empty;
}
var groups = candidates
.GroupBy(i => _comparer.BuildSignature(i.TaskOverride.TrackOverrides))
.Select(g => new { Signature = g.Key, Items = g.OrderBy(i => i.OrderNumber).ToList() })
.OrderByDescending(g => g.Items.Count)
.ThenBy(g => g.Items[0].OrderNumber)
.ToList();
if (groups.Count == 0)
{
return BulkTrackSelectionAnalysis.Empty;
}
var top = groups[0];
if (groups.Count > 1 && groups[1].Items.Count == top.Items.Count)
{
return new BulkTrackSelectionAnalysis(null, [], candidates.OrderBy(i => i.OrderNumber).ToList(), false);
}
var skipped = candidates.Except(top.Items).OrderBy(i => i.OrderNumber).ToList();
return new BulkTrackSelectionAnalysis(top.Signature, top.Items, skipped, true);
}
public void ApplyBulkEdits(
IReadOnlyList<ConversionQueueItem> targets,
IReadOnlyList<TrackOverrideEntry> editedTemplateTracks)
{
foreach (var item in targets)
{
if (item.TaskOverride.TrackOverrides.Count != editedTemplateTracks.Count)
{
continue;
}
for (var i = 0; i < editedTemplateTracks.Count; i++)
{
var dst = item.TaskOverride.TrackOverrides[i];
var src = editedTemplateTracks[i];
dst.Action = src.Action;
dst.AudioBitrateKbps = src.AudioBitrateKbps;
dst.Default = src.Default;
dst.Language = src.Language;
dst.Title = src.Title;
}
}
}
}
public sealed record BulkTrackSelectionAnalysis(
string? MajoritySignature,
IReadOnlyList<ConversionQueueItem> MajorityItems,
IReadOnlyList<ConversionQueueItem> SkippedItems,
bool HasMajority)
{
public static BulkTrackSelectionAnalysis Empty { get; } = new(null, [], [], false);
}

View File

@ -0,0 +1,42 @@
using System.Text;
namespace EmbyToolbox.Services;
public sealed class ChapterBuilderService
{
public string BuildFfmetadata(IReadOnlyList<string> chapterTitles, IReadOnlyList<double> durationsSeconds)
{
if (chapterTitles.Count != durationsSeconds.Count)
{
throw new ArgumentException("Количество глав не совпадает с количеством длительностей.");
}
var sb = new StringBuilder();
sb.AppendLine(";FFMETADATA1");
long currentStartMs = 0;
for (var i = 0; i < chapterTitles.Count; i++)
{
var durationMs = Math.Max(1L, (long)Math.Round(Math.Max(0.001, durationsSeconds[i]) * 1000.0, MidpointRounding.AwayFromZero));
var endMs = currentStartMs + durationMs - 1;
var title = string.IsNullOrWhiteSpace(chapterTitles[i]) ? $"Часть {i + 1}" : chapterTitles[i].Trim();
sb.AppendLine("[CHAPTER]");
sb.AppendLine("TIMEBASE=1/1000");
sb.AppendLine($"START={currentStartMs}");
sb.AppendLine($"END={endMs}");
sb.AppendLine($"title={EscapeMetadataValue(title)}");
currentStartMs += durationMs;
}
return sb.ToString();
}
private static string EscapeMetadataValue(string value) =>
value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("=", "\\=", StringComparison.Ordinal)
.Replace(";", "\\;", StringComparison.Ordinal)
.Replace("#", "\\#", StringComparison.Ordinal)
.Replace(Environment.NewLine, " ", StringComparison.Ordinal)
.Replace("\n", " ", StringComparison.Ordinal)
.Replace("\r", " ", StringComparison.Ordinal);
}

View File

@ -0,0 +1,754 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class ConversionExecutionService
{
private readonly LoggingService _logging;
private readonly FfmpegCommandBuilder _builder;
private readonly FfmpegService _ffmpeg;
private readonly FfprobeService _ffprobe;
private readonly FfmpegEncoderDiscoveryService _encoderDiscovery;
private readonly Func<string> _resolveHardwareAcceleration;
private readonly SafeFileReplaceService _replace;
private readonly ExternalFileCleanupService _cleanup;
public ConversionExecutionService(
LoggingService logging,
FfmpegCommandBuilder builder,
FfmpegService ffmpeg,
FfprobeService ffprobe,
FfmpegEncoderDiscoveryService encoderDiscovery,
Func<string> resolveHardwareAcceleration,
SafeFileReplaceService replace,
ExternalFileCleanupService cleanup)
{
_logging = logging;
_builder = builder;
_ffmpeg = ffmpeg;
_ffprobe = ffprobe;
_encoderDiscovery = encoderDiscovery;
_resolveHardwareAcceleration = resolveHardwareAcceleration;
_replace = replace;
_cleanup = cleanup;
}
public async Task RunQueueAsync(
IReadOnlyList<ConversionQueueItem> items,
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
string? tempRoot,
string runId,
Func<Action, Task> uiInvoke,
CancellationToken cancellationToken)
{
_logging.Info($"старт обработки очереди: {items.Count}", "conversion.exec");
_ = _encoderDiscovery.GetAvailableEncoders(_logging);
var root = EnsureTempRoot(tempRoot);
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
if (item.Status is not (ConversionQueueStatus.Ready or ConversionQueueStatus.Pending))
{
continue;
}
await RunSingleAsync(item, resolveProfile, root, runId, uiInvoke, cancellationToken).ConfigureAwait(false);
}
}
private async Task RunSingleAsync(
ConversionQueueItem item,
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
string tempRoot,
string runId,
Func<Action, Task> uiInvoke,
CancellationToken token)
{
var tempOut = string.Empty;
var finalPath = item.FullPath;
var targetContainer = string.Empty;
FfmpegCommand? ffmpegCmdForDisposableDumps = null;
try
{
if (item.IsSkipPlan)
{
await uiInvoke(
() =>
{
item.Status = ConversionQueueStatus.Done;
item.Progress = 100;
item.IsProcessed = true;
item.ProcessedInCurrentRun = true;
item.LastRunId = runId;
item.ErrorMessage = null;
item.ErrorDetails = null;
}).ConfigureAwait(false);
_logging.Info($"Файл пропущен: обработка не требуется. {item.FullPath}", "conversion.exec");
return;
}
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.{Guid.NewGuid():N}.tmp{Path.GetExtension(item.FileName)}");
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Running;
item.Progress = 0;
item.IsProcessed = true;
item.ProcessedInCurrentRun = false;
item.LastRunId = runId;
item.ErrorMessage = null;
item.ErrorDetails = null;
}).ConfigureAwait(false);
var profile = resolveProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
targetContainer = ResolveTargetContainer(item, profile);
finalPath = BuildFinalPath(item.FullPath, targetContainer);
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.__processing__{GetOutputExtension(targetContainer)}");
var selectedAcceleration = _resolveHardwareAcceleration();
var requiresVideoTranscode = RequiresVideoTranscode(item, profile);
var usedTsTimestampRetryTranscode = false;
if (MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName))
{
_logging.Info(
"MPEG-TS: включено исправление временных меток (genpts; avoid_negative_ts; muxdelay/muxpreload)",
"conversion.exec");
if (item.LastPlan?.RequiresTimestampFix == true)
{
_logging.Info(
"План: MPEG-TS → MKV — режим коррекции timestamps (при ошибке copy видео будет перекодирование)",
"conversion.exec");
}
}
var videoEncoder = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, requiresVideoTranscode);
var cmd = _builder.Build(item, profile, tempOut, videoEncoder, requiresVideoTranscode);
ffmpegCmdForDisposableDumps = cmd;
if (!string.Equals(Path.GetExtension(item.FullPath), Path.GetExtension(finalPath), StringComparison.OrdinalIgnoreCase))
{
_logging.Info($"Remux: {Path.GetExtension(item.FullPath).TrimStart('.').ToUpperInvariant()} -> {Path.GetExtension(finalPath).TrimStart('.').ToUpperInvariant()}", "conversion.exec");
}
_logging.Info($"Начало обработки файла: {item.FullPath}", "conversion.exec");
_logging.Info($"План:{Environment.NewLine}{item.PlanSummary}", "conversion.exec");
_logging.Info($"Временный файл результата: {tempOut}", "conversion.exec");
_logging.Info($"Финальный файл: {finalPath}", "conversion.exec");
LogVideoEncodeSession(item, profile, cmd, videoEncoder);
LogPlannedActions(item, profile, cmd, requiresVideoTranscode);
_logging.Info("FFmpeg: старт объединенной обработки файла", "conversion.exec", command: cmd.FullCommand);
var ffProgress = new Progress<FfmpegProgressSnapshot>(
p =>
{
_ = uiInvoke(
() =>
{
if (p.IsIndeterminate)
{
if (item.Progress < 1)
{
item.Progress = 1;
}
}
else if (p.Percent is { } encPct)
{
// 0..99% ffmpeg → очередь 0..89 (floor); ffmpeg 100% (progress=end) → 90
var mapped = encPct >= 100
? 90
: Math.Clamp((int)Math.Floor(encPct / 100.0 * 90.0), 0, 89);
item.Progress = mapped;
}
});
});
var result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
if (!result.Success
&& !requiresVideoTranscode
&& MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName)
&& MpegTsTimestampHelpers.LooksLikeTimestampMuxFailure(result.StdErr))
{
_logging.Warning(
"Video copy failed due to missing timestamps, retrying with video transcode",
"conversion.exec",
stderr: result.StdErr);
_logging.Info(
"MPEG-TS: повтор с перекодированием видео из-за ошибки временных меток",
"conversion.exec");
TryDeleteDisposableAttachmentDumpFiles(cmd);
TryDeleteTemp(tempOut);
tempOut = Path.Combine(
tempRoot,
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryTs__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
usedTsTimestampRetryTranscode = true;
var videoEncoderRetry = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, true);
cmd = _builder.Build(item, profile, tempOut, videoEncoderRetry, requiresVideoTranscode: false, forceVideoTranscode: true);
ffmpegCmdForDisposableDumps = cmd;
_logging.Info($"Новый временный файл (повтор): {tempOut}", "conversion.exec");
LogVideoEncodeSession(item, profile, cmd, videoEncoderRetry);
_logging.Info("FFmpeg: повтор с перекодированием видео", "conversion.exec", command: cmd.FullCommand);
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
}
if (!result.Success
&& requiresVideoTranscode
&& CommandArgumentsUseNvencVideo(cmd.ArgumentList)
&& LooksLikeNvencPipelineFailure(result.StdErr))
{
_logging.Warning(
"NVENC failed, retrying with libx264",
"conversion.exec",
stderr: result.StdErr);
TryDeleteDisposableAttachmentDumpFiles(cmd);
TryDeleteTemp(tempOut);
tempOut = Path.Combine(
tempRoot,
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryCpuNv__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
var cpuFallback = FfmpegCommandBuilder.CreateCpuFallbackVideoEncoder(item, profile);
cmd = _builder.Build(item, profile, tempOut, cpuFallback, requiresVideoTranscode);
ffmpegCmdForDisposableDumps = cmd;
LogVideoEncodeSession(item, profile, cmd, cpuFallback);
_logging.Info($"Новый временный файл (NVENC→CPU): {tempOut}", "conversion.exec");
_logging.Info("FFmpeg: повтор после сбоя NVENC (CPU кодер)", "conversion.exec", command: cmd.FullCommand);
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
}
if (!result.Success)
{
var err = string.IsNullOrWhiteSpace(result.StdErr) ? $"exit={result.ExitCode}" : ShortenForLog(result.StdErr, 2000);
_logging.Error($"FFmpeg завершен с ошибкой: {err}", "conversion.exec", stderr: result.StdErr);
var brief = string.IsNullOrWhiteSpace(result.StdErr)
? $"ffmpeg завершился с кодом {result.ExitCode}."
: ShortenForUiOneLine(result.StdErr.Trim(), 480);
var detail = string.IsNullOrWhiteSpace(result.StdErr) ? null : result.StdErr.Trim();
TryDeleteTemp(tempOut);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Error;
item.ErrorMessage = brief;
item.ErrorDetails = detail;
item.ProcessedInCurrentRun = false;
}).ConfigureAwait(false);
return;
}
_logging.Info("FFmpeg завершен успешно", "conversion.exec");
await ValidateOutputAsync(tempOut, item, profile, requiresVideoTranscode || usedTsTimestampRetryTranscode, token).ConfigureAwait(false);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Copying;
item.Progress = 90;
}).ConfigureAwait(false);
_logging.Info("Начато копирование результата", "conversion.exec");
var sameRoot = string.Equals(Path.GetPathRoot(finalPath), Path.GetPathRoot(tempOut), StringComparison.OrdinalIgnoreCase);
if (sameRoot)
{
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Replacing;
}).ConfigureAwait(false);
_logging.Info("Замена исходного файла", "conversion.exec");
}
var replaceProgress = new Progress<int>(
p =>
{
_ = uiInvoke(() => item.Progress = p);
});
await _replace.ReplaceAsync(item.FullPath, finalPath, tempOut, replaceProgress, token).ConfigureAwait(false);
_logging.Info("успешная замена исходника", "conversion.exec");
if (!string.Equals(item.FullPath, finalPath, StringComparison.OrdinalIgnoreCase))
{
_logging.Info("Старый файл удален после успешной замены", "conversion.exec");
}
var usedFonts = cmd.UsedExternalFiles
.Where(x => x.IsFont)
.DistinctBy(x => x.FullPath)
.Count();
if (usedFonts > 0)
{
_logging.Info($"Использованы шрифты: {usedFonts}", "conversion.exec");
_logging.Info("Шрифты оставлены на месте", "conversion.exec");
}
var moved = _cleanup.MoveUsedToUseless(finalPath, cmd.UsedExternalFiles);
if (moved.Count > 0)
{
_logging.Info($"Перемещены внешние файлы в useless: {moved.Count}", "conversion.exec");
}
await uiInvoke(() =>
{
item.UpdateOutputPath(finalPath, targetContainer);
item.Progress = 100;
item.Status = ConversionQueueStatus.Done;
item.ProcessedInCurrentRun = true;
}).ConfigureAwait(false);
_logging.Info($"Итоговый путь обновлен: {finalPath}", "conversion.exec");
_logging.Info("Файл обработан успешно", "conversion.exec");
}
catch (OperationCanceledException)
{
_logging.Warning("отмена пользователем", "conversion.exec");
TryDeleteTemp(tempOut);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Cancelled;
item.ErrorMessage = "Отмена пользователем";
item.ErrorDetails = null;
}).ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
TryDeleteTemp(tempOut);
_logging.Error($"ошибка обработки: {ex.Message}", "conversion.exec", ex);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Error;
item.ErrorMessage = ex.Message;
item.ErrorDetails = null;
}).ConfigureAwait(false);
}
finally
{
TryDeleteDisposableAttachmentDumpFiles(ffmpegCmdForDisposableDumps);
}
}
private static void TryDeleteDisposableAttachmentDumpFiles(FfmpegCommand? cmd)
{
if (cmd?.DisposableAttachmentDumpPaths is not { Count: > 0 })
{
return;
}
foreach (var path in cmd.DisposableAttachmentDumpPaths.Distinct(StringComparer.OrdinalIgnoreCase))
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// временные дампы для -attach после завершения ffmpeg
}
}
}
private void LogPlannedActions(ConversionQueueItem item, ConversionProfileSettingsEntry profile, FfmpegCommand cmd, bool requiresVideoTranscode)
{
var ovr = item.TaskOverride;
var targetContainer = string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer;
var sourceContainer = item.MediaAnalysis?.ContainerFormat ?? "unknown";
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
{
_logging.Info($"FFmpeg: старт remux контейнера {sourceContainer} -> {targetContainer}", "conversion.exec");
}
var sourceVideo = item.MediaAnalysis?.PrimaryVideo?.CodecName ?? "unknown";
var targetVideo = string.IsNullOrWhiteSpace(ovr.TargetVideo) ? profile.Video : ovr.TargetVideo;
if (requiresVideoTranscode || ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert))
{
_logging.Info($"FFmpeg: старт конвертации видео {sourceVideo} -> {targetVideo}", "conversion.exec");
}
var sb = new StringBuilder();
sb.AppendLine("FFmpeg команда для файла:");
var mappedLines = 0;
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($"- remux в {targetContainer}");
mappedLines++;
}
if (!requiresVideoTranscode && ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Keep))
{
sb.AppendLine("- video copy");
mappedLines++;
}
foreach (var t in ovr.TrackOverrides.OrderBy(x => x.StreamKind).ThenBy(x => x.StreamIndex))
{
var src = GetSourceStream(item, t);
var lang = FormatLang(src?.Language ?? t.Language);
if (t.Action == TrackActionKind.Convert && t.StreamKind == MediaStreamKind.Audio)
{
var fromCodec = src?.CodecName ?? "unknown";
var bitrate = string.IsNullOrWhiteSpace(t.AudioBitrateKbps) ? (string.IsNullOrWhiteSpace(ovr.TargetAudioBitrate) ? profile.Bitrate : ovr.TargetAudioBitrate) : t.AudioBitrateKbps!;
_logging.Info($"FFmpeg: старт конвертации audio #{DisplayIndex(t)} {fromCodec} -> aac {bitrate}", "conversion.exec");
sb.AppendLine($"- audio #{DisplayIndex(t)} convert to AAC {bitrate}");
mappedLines++;
continue;
}
if (t.Action == TrackActionKind.Remove && t.Source == SourceKind.Embedded && t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle)
{
var kind = t.StreamKind == MediaStreamKind.Audio ? "audio" : "subtitle";
var subKind = string.Empty;
if (t.StreamKind == MediaStreamKind.Subtitle &&
SubtitleCodecRules.IsTeletext(src?.CodecName))
{
subKind = " (teletext)";
}
_logging.Info($"FFmpeg: старт удаления дорожки {kind}{subKind} #{DisplayIndex(t)} ({lang})", "conversion.exec");
sb.AppendLine(subKind.Length > 0
? $"- remove teletext subtitle #{DisplayIndex(t)}"
: $"- remove {kind} #{DisplayIndex(t)}");
mappedLines++;
continue;
}
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalPath))
{
_logging.Info($"FFmpeg: старт добавления внешней аудиодорожки: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
sb.AppendLine("- add external audio");
mappedLines++;
continue;
}
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Subtitle && !string.IsNullOrWhiteSpace(t.ExternalPath))
{
_logging.Info($"FFmpeg: старт добавления внешних субтитров: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
sb.AppendLine("- add external subtitle");
mappedLines++;
continue;
}
}
var fonts = cmd.UsedExternalFiles.Where(f => f.IsFont).DistinctBy(f => f.FullPath).Count();
if (fonts > 0)
{
_logging.Info($"FFmpeg: старт встраивания шрифтов: {fonts} файлов", "conversion.exec");
sb.AppendLine($"- attach fonts: {fonts}");
mappedLines++;
}
if (mappedLines == 0)
{
sb.AppendLine("- copy streams");
}
_logging.Info(sb.ToString().TrimEnd(), "conversion.exec");
}
private static MediaStreamInfo? GetSourceStream(ConversionQueueItem item, TrackOverrideEntry t)
{
if (t.Source != SourceKind.Embedded || t.StreamIndex < 0 || item.MediaAnalysis is null)
{
return null;
}
return item.MediaAnalysis.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
}
private static int DisplayIndex(TrackOverrideEntry t) => t.StreamIndex >= 0 ? t.StreamIndex + 1 : -1;
private static string FormatLang(string? lang) => string.IsNullOrWhiteSpace(lang) ? "unknown" : lang.Trim();
private static string ShortenForUiOneLine(string text, int maxLen)
{
var one = text.ReplaceLineEndings(" ").Trim();
while (one.Contains(" ", StringComparison.Ordinal))
{
one = one.Replace(" ", " ", StringComparison.Ordinal);
}
return ShortenForLog(one, maxLen);
}
private static string ShortenForLog(string text, int maxLen)
{
if (text.Length <= maxLen)
{
return text;
}
return text[..maxLen] + "...";
}
private static string EnsureTempRoot(string? path)
{
var root = string.IsNullOrWhiteSpace(path)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EmbyToolbox", "Temp")
: path.Trim();
Directory.CreateDirectory(root);
return root;
}
private static void TryDeleteTemp(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignore
}
}
private static string ResolveTargetContainer(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
{
var target = string.IsNullOrWhiteSpace(item.TaskOverride.TargetContainer) ? profile.Container : item.TaskOverride.TargetContainer;
return string.IsNullOrWhiteSpace(target) ? profile.Container : target.Trim();
}
private static string BuildFinalPath(string sourcePath, string targetContainer)
{
var dir = Path.GetDirectoryName(sourcePath) ?? string.Empty;
var name = Path.GetFileNameWithoutExtension(sourcePath);
return Path.Combine(dir, name + GetOutputExtension(targetContainer));
}
private static string GetOutputExtension(string targetContainer)
{
if (targetContainer.Contains("mkv", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("matro", StringComparison.OrdinalIgnoreCase))
{
return ".mkv";
}
if (targetContainer.Contains("mp4", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("mov", StringComparison.OrdinalIgnoreCase))
{
return ".mp4";
}
return ".mkv";
}
private VideoEncoderSettings? ResolveVideoEncoderForItem(
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
string selectedAcceleration,
bool requiresVideoTranscode)
{
if (!requiresVideoTranscode)
{
return null;
}
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo)
? profile.Video
: item.TaskOverride.TargetVideo;
return _encoderDiscovery.ResolveVideoEncoder(
selectedAcceleration,
targetVideo,
autoFallbackToCpu: true,
_logging);
}
private static bool RequiresVideoTranscode(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
{
if (item.MediaAnalysis is null)
{
return item.TaskOverride.TrackOverrides.Any(t =>
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
}
return ConversionPlanService.RequiresVideoTranscode(item.MediaAnalysis, profile, item.TaskOverride)
|| item.TaskOverride.TrackOverrides.Any(t =>
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
}
private async Task ValidateOutputAsync(
string outputPath,
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
bool requiresVideoTranscode,
CancellationToken token)
{
if (!requiresVideoTranscode)
{
return;
}
var probe = await _ffprobe.AnalyzeAsync(outputPath, token).ConfigureAwait(false);
if (!probe.IsSuccess)
{
throw new InvalidOperationException("ffprobe результата завершился с ошибкой");
}
var parsed = MediaAnalysisParser.TryParse(probe.Json);
var video = parsed?.PrimaryVideo;
if (video is null)
{
throw new InvalidOperationException("в результирующем файле не найден видеопоток");
}
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
var expectedCodec = targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase)
|| targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase)
? "hevc"
: "h264";
var actualCodec = video.CodecName ?? string.Empty;
var codecOk = expectedCodec == "h264"
? actualCodec.Contains("h264", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("avc", StringComparison.OrdinalIgnoreCase)
: actualCodec.Contains("hevc", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("h265", StringComparison.OrdinalIgnoreCase);
if (!codecOk)
{
throw new InvalidOperationException($"проверка результата не пройдена: video codec={actualCodec}, ожидается {expectedCodec}");
}
var targetPixelUi = string.IsNullOrWhiteSpace(item.TaskOverride.TargetPixelFormat)
? profile.PixelFormat
: item.TaskOverride.TargetPixelFormat;
var expectedNorm = ResolveExpectedOutputPixNorm(targetPixelUi, expectedCodec);
if (expectedNorm is null)
{
return;
}
var actualNorm = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(video.PixelFormat);
if (string.IsNullOrWhiteSpace(actualNorm))
{
throw new InvalidOperationException(
$"проверка результата не пройдена: pixel format недоступен (ffprobe: {video.PixelFormat ?? "(null)"})");
}
if (!PixelFormatsMatchForValidation(expectedNorm, actualNorm))
{
throw new InvalidOperationException(
$"проверка результата не пройдена: pix_fmt={video.PixelFormat}, ожидается {expectedNorm}");
}
}
private static bool IsPixelFormatUiOpen(string? s) =>
!string.IsNullOrWhiteSpace(s)
&& !s.Contains("без", StringComparison.OrdinalIgnoreCase)
&& !s.Contains("No change", StringComparison.OrdinalIgnoreCase);
/// <summary>Ожидаемый нормализованный pix_fmt для проверки; с «без изменений» типичное yuv420p для AVC/HEVC.</summary>
private static string? ResolveExpectedOutputPixNorm(string mergedPixelFmtUiFromProfile, string expectedCodecFourccStyle)
{
if (IsPixelFormatUiOpen(mergedPixelFmtUiFromProfile))
{
return FfmpegCommandBuilder.NormalizeFfprobePixelFormat(mergedPixelFmtUiFromProfile!.Trim());
}
if (expectedCodecFourccStyle.Equals("hevc", StringComparison.OrdinalIgnoreCase)
|| expectedCodecFourccStyle.Equals("h264", StringComparison.OrdinalIgnoreCase))
{
return "yuv420p";
}
return null;
}
private void LogVideoEncodeSession(
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
FfmpegCommand cmd,
VideoEncoderSettings? encoderHint)
{
if (cmd.VideoEncoderForLog is null)
{
return;
}
var pv = item.MediaAnalysis?.PrimaryVideo;
var srcPix = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(pv?.PixelFormat) ?? "?";
var srcCodec = string.IsNullOrWhiteSpace(pv?.CodecName) ? "?" : pv.CodecName;
var mergedPxUi = FfmpegCommandBuilder.ResolveMergedTargetPixelFormatUi(item.TaskOverride, profile);
string tgtEffPix;
var codecForEff = encoderHint?.Codec ?? cmd.VideoEncoderForLog;
tgtEffPix = FfmpegCommandBuilder.TryGetEffectiveVideoOutputPixFmt(mergedPxUi, codecForEff, out var effPx)
? effPx!
: "?";
var mergedVidRaw = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
var tgtVid = FormatMergedTargetVideoForLog(mergedVidRaw);
_logging.Info($"Video transcode: {srcCodec} / {srcPix} -> {tgtVid} / {tgtEffPix}", "conversion.exec");
if (cmd.AppliedVideoFilters.Count > 0)
{
_logging.Info($"Video filter: {string.Join(",", cmd.AppliedVideoFilters)}", "conversion.exec");
}
_logging.Info($"Encoder: {cmd.VideoEncoderForLog}", "conversion.exec");
}
private static string FormatMergedTargetVideoForLog(string mergedVideo)
{
var v = mergedVideo.Trim();
if (string.IsNullOrWhiteSpace(v))
{
return "?";
}
if (v.Contains("265", StringComparison.OrdinalIgnoreCase)
|| v.Contains("hevc", StringComparison.OrdinalIgnoreCase))
{
return "H.265 / HEVC";
}
if (v.Contains("264", StringComparison.OrdinalIgnoreCase))
{
return "H.264";
}
return v;
}
private static bool CommandArgumentsUseNvencVideo(IReadOnlyList<string> args)
{
for (var i = 0; i < args.Count - 1; i++)
{
if (!args[i].StartsWith("-c:v", StringComparison.Ordinal))
{
continue;
}
if (args[i + 1].Contains("nvenc", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool LooksLikeNvencPipelineFailure(string stderr) =>
!string.IsNullOrWhiteSpace(stderr) &&
(stderr.Contains("Impossible to convert between the formats", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("auto_scale_", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("Error reinitializing filters", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("Function not implemented", StringComparison.OrdinalIgnoreCase)
|| (stderr.Contains("nvenc", StringComparison.OrdinalIgnoreCase)
&& stderr.Contains("Could not open encoder", StringComparison.OrdinalIgnoreCase)));
/// <summary>
/// ffprobe может вернуть yuvj420p (JPEG full-range) там, где в профиле задано yuv420p (limited);
/// по сути тот же 8-bit 4:2:0 планарный — для Emby/совместимости считаем допустимым совпадением.
/// </summary>
private static bool PixelFormatsMatchForValidation(string expectedNorm, string actualNorm)
{
if (string.Equals(expectedNorm, actualNorm, StringComparison.OrdinalIgnoreCase))
{
return true;
}
static bool IsYuv420pFamily(string p) =>
p.Equals("yuv420p", StringComparison.OrdinalIgnoreCase)
|| p.Equals("yuvj420p", StringComparison.OrdinalIgnoreCase);
return IsYuv420pFamily(expectedNorm) && IsYuv420pFamily(actualNorm);
}
}

View File

@ -0,0 +1,848 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Строит план сравнения с профилем: без вызова ffmpeg.</summary>
public sealed class ConversionPlanService
{
/// <summary>Встроенная аудиодорожка уже соответствует целевому аудиокодеку профиля (AAC и т.д.).</summary>
public static bool EmbeddedAudioMatchesProfile(string? fileCodec, ConversionProfileSettingsEntry profile) =>
AudioCodecMatchesAgainstLabel(fileCodec, profile.Audio);
/// <summary>Сравнение кодека файла с подписью цели (как в snapshot), без полного профиля.</summary>
public static bool VideoCodecMatchesTarget(string? fileCodec, string targetVideoLabel) =>
VideoCodecMatches(fileCodec, targetVideoLabel);
public static bool AudioCodecMatchesTarget(string? fileCodec, string targetAudioLabel) =>
AudioCodecMatchesAgainstLabel(fileCodec, targetAudioLabel);
public ConversionPlan Build(
MediaAnalysisResult media,
IReadOnlyList<SidecarFile> sidecars,
ConversionProfileSettingsEntry profile,
ConversionTaskOverride? ovr,
IReadOnlyList<ExternalAudioFile>? externalAudioForBaseline = null)
{
var steps = new List<string>();
var p = MergeProfile(profile, ovr);
var v = media.PrimaryVideo;
var cont = (media.ContainerFormat ?? string.Empty).ToLowerInvariant();
var requiresTimestampFix = MpegTsTimestampHelpers.IsMpegTsInput(media, null) && WantsMkv(p) && !IsMkvContainer(cont);
if (WantsMkv(p) && !IsMkvContainer(cont))
{
steps.Add("Remux to MKV");
}
if (requiresTimestampFix)
{
steps.Add("MPEG-TS: исправление меток времени (genpts / mux; при сбое — перекодирование видео)");
}
else if (WantsMp4(p) && !IsMp4ish(cont) && !HasStep(steps, "Remux"))
{
steps.Add("Remux to MP4");
}
if (v is not null)
{
if (!VideoCodecMatches(v.CodecName, p.Video))
{
steps.Add("Convert video to " + p.Video);
}
if (!string.IsNullOrEmpty(p.PixelFormat) && !IsUnchanged(p.PixelFormat) &&
!StringEqualsLoose(v.PixelFormat, ToPixNorm(p.PixelFormat)))
{
steps.Add("Convert pixel format to " + p.PixelFormat);
}
if (!string.IsNullOrEmpty(p.Resolution) && !IsUnchanged(p.Resolution))
{
if (v.Width is { } w && v.Height is { } h)
{
if (!ResMatchesFile(w, h, p.Resolution))
{
steps.Add("Resolution: " + p.Resolution);
}
}
}
if (!string.IsNullOrEmpty(p.Fps) && !IsUnchanged(p.Fps) && v.FrameRate is { } f)
{
if (TryParseMaxValue(p.Fps) is { } fpsMax)
{
if (f > fpsMax + FpsEpsilon)
{
steps.Add($"FPS: максимум {FormatNumber(fpsMax)}");
}
}
}
}
var targetVideoBitrateKbps = VideoBitratePolicy.ResolveTargetKbps(
p.VideoBitrateMode,
p.VideoBitrateMbps,
media);
var normalizedVideoBitrateMode = VideoBitratePolicy.NormalizeMode(p.VideoBitrateMode);
var videoWillTranscode = RequiresVideoTranscode(media, profile, ovr);
if (videoWillTranscode)
{
var bitrateStep = BuildVideoBitrateStep(normalizedVideoBitrateMode, targetVideoBitrateKbps);
if (!string.IsNullOrWhiteSpace(bitrateStep))
{
steps.Add(bitrateStep);
}
}
var hasOverrideList = ovr is { TrackOverrides.Count: > 0 };
if (hasOverrideList && ovr is not null)
{
AppendSubtitlePlanSteps(media, ovr, p, steps);
}
if (hasOverrideList && ovr!.TrackOverrides.Any(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Audio, Action: TrackActionKind.Convert }))
{
if (!string.IsNullOrEmpty(p.Audio) && !IsUnchanged(p.Audio))
{
steps.Add("Convert audio to " + p.Audio + " " + p.Bitrate);
}
}
else if (!hasOverrideList)
{
if (!string.IsNullOrEmpty(p.Audio) && !IsUnchanged(p.Audio))
{
var want = p.Audio;
if (media.AudioStreams.Any() && !media.AudioStreams.All(a => AudioCodecMatchesAgainstLabel(a.CodecName, p.Audio)))
{
steps.Add("Convert audio to " + p.Audio + " " + p.Bitrate);
}
}
}
if (p.Subtitles is "Нет" or "No" or "false")
{
if (media.SubtitleStreams.Count > 0)
{
steps.Add("Remove non-RUS subtitles");
}
}
else
{
if (ProfileRemovesNonRusSubs(p) && media.SubtitleStreams.Count > 0)
{
steps.Add("Remove non-RUS subtitles");
}
}
if (p.ExternalSubtitles is "Да" or "Yes" or "true")
{
if (sidecars.Any(s => s.IsSubtitle))
{
steps.Add("Add external subtitles rus default");
}
}
if (p.ExternalTracks is "Да" or "Yes" or "true")
{
var externalAudioAdds = ovr?.TrackOverrides
.Where(t => t is
{
Source: SourceKind.External,
StreamKind: MediaStreamKind.Audio,
Action: TrackActionKind.Add
})
.ToList();
if (externalAudioAdds is { Count: > 0 })
{
if (externalAudioAdds.All(t => IsAacCodec(t.ExternalStreamCodec)))
{
steps.Add("Add external audio AAC copy");
}
else
{
steps.Add("Add external audio -> AAC 256 kbps");
}
}
else if (sidecars.Any(s => s.IsAudio))
{
steps.Add("Add external audio rus default");
}
}
var plannedFontAdds = ovr?.TrackOverrides.Count(
t => t is
{
Source: SourceKind.External,
StreamKind: MediaStreamKind.Attachment,
Action: TrackActionKind.Add
}) ?? 0;
if (plannedFontAdds > 0)
{
if (SupportsAttachments(p.Container))
{
steps.Add("Add external fonts attachments");
}
else
{
steps.Add("Fonts skipped: container does not support attachments");
}
}
if (media.DataStreams.Count > 0)
{
steps.Add("Remove data streams");
}
var stats = ConversionPlanActionStats.FromOverrides(ovr);
var hasRealActions = HasRealActions(steps, stats, media, sidecars, profile, ovr, externalAudioForBaseline);
var shortSummary = BuildCountSummary(p, media, steps, stats, ovr, plannedFontAdds, hasRealActions);
var trackParts = BuildTrackParts(ovr, p.Container, media);
if (!hasRealActions)
{
return new ConversionPlan
{
SuggestsSkip = true,
HasRealActions = false,
StepDescriptions = new[] { "Skip — обработка не требуется" },
ActionStats = stats,
TrackParts = trackParts,
ShortSummary = "Skip — обработка не требуется",
TargetVideoBitrateMode = normalizedVideoBitrateMode,
TargetVideoBitrateKbps = targetVideoBitrateKbps,
RequiresTimestampFix = requiresTimestampFix
};
}
return new ConversionPlan
{
SuggestsSkip = false,
HasRealActions = true,
StepDescriptions = steps,
ActionStats = stats,
TrackParts = trackParts,
ShortSummary = shortSummary,
TargetVideoBitrateMode = normalizedVideoBitrateMode,
TargetVideoBitrateKbps = targetVideoBitrateKbps,
RequiresTimestampFix = requiresTimestampFix
};
}
public static bool RequiresVideoTranscode(
MediaAnalysisResult media,
ConversionProfileSettingsEntry profile,
ConversionTaskOverride? ovr)
{
var v = media.PrimaryVideo;
if (v is null)
{
return false;
}
var p = MergeProfile(profile, ovr);
if (!VideoCodecMatches(v.CodecName, p.Video))
{
return true;
}
if (!string.IsNullOrEmpty(p.PixelFormat) && !IsUnchanged(p.PixelFormat) &&
!StringEqualsLoose(v.PixelFormat, ToPixNorm(p.PixelFormat)))
{
return true;
}
if (!string.IsNullOrEmpty(p.Resolution) && !IsUnchanged(p.Resolution))
{
if (v.Width is { } w && v.Height is { } h && !ResMatchesFile(w, h, p.Resolution))
{
return true;
}
}
if (!string.IsNullOrEmpty(p.Fps) && !IsUnchanged(p.Fps) && v.FrameRate is { } f)
{
if (TryParseMaxValue(p.Fps) is { } fpsMax && f > fpsMax + FpsEpsilon)
{
return true;
}
}
var normalizedVideoBitrateMode = VideoBitratePolicy.NormalizeMode(p.VideoBitrateMode);
if (normalizedVideoBitrateMode == VideoBitratePolicy.Auto)
{
return false;
}
if (normalizedVideoBitrateMode == VideoBitratePolicy.Source && media.SourceVideoBitrateBps is null)
{
return false;
}
var targetVideoLabel = p.Video ?? string.Empty;
if (targetVideoLabel.Contains("copy", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
private static bool HasStep(IReadOnlyList<string> s, string sub) => s.Any(x => x.Contains(sub, StringComparison.OrdinalIgnoreCase));
private static string BuildCountSummary(
ConversionProfileSettingsEntry p,
MediaAnalysisResult m,
IReadOnlyList<string> steps,
ConversionPlanActionStats stats,
ConversionTaskOverride? ovr,
int plannedFontAdds,
bool hasRealActions)
{
var parts = new List<string>();
foreach (var step in steps.Where(s => !string.IsNullOrWhiteSpace(s)))
{
parts.Add(step);
}
var addSub = 0;
var addAudio = 0;
var addFonts = 0;
var removeAudio = 0;
var removeSubtitle = 0;
var removeOther = 0;
if (ovr is { TrackOverrides.Count: > 0 })
{
addSub = ovr.TrackOverrides.Count(
t => t is
{
Source: SourceKind.External,
StreamKind: MediaStreamKind.Subtitle,
Action: TrackActionKind.Add
});
addAudio = ovr.TrackOverrides.Count(
t => t is
{
Source: SourceKind.External,
StreamKind: MediaStreamKind.Audio,
Action: TrackActionKind.Add
});
addFonts = ovr.TrackOverrides.Count(
t => t is
{
Source: SourceKind.External,
StreamKind: MediaStreamKind.Attachment,
Action: TrackActionKind.Add
});
removeAudio = ovr.TrackOverrides.Count(
t => t is
{
Source: SourceKind.Embedded,
StreamKind: MediaStreamKind.Audio,
Action: TrackActionKind.Remove
});
removeSubtitle = ovr.TrackOverrides.Count(
t => t is
{
Source: SourceKind.Embedded,
StreamKind: MediaStreamKind.Subtitle,
Action: TrackActionKind.Remove
});
removeOther = ovr.TrackOverrides.Count(
t => t is { Source: SourceKind.Embedded, Action: TrackActionKind.Remove }
&& t.StreamKind is not MediaStreamKind.Audio
&& t.StreamKind is not MediaStreamKind.Subtitle);
}
if (addSub > 0)
{
parts.Add("Add external subtitle: " + addSub);
}
if (addAudio > 0)
{
parts.Add("Add external audio: " + addAudio);
}
if (plannedFontAdds > 0)
{
if (SupportsAttachments(p.Container))
{
parts.Add("Add fonts: " + addFonts);
}
}
if (removeSubtitle > 0)
{
parts.Add("Remove subtitle: " + removeSubtitle);
}
if (removeAudio > 0)
{
parts.Add("Remove audio: " + removeAudio);
}
if (removeOther > 0)
{
parts.Add("Remove tracks: " + removeOther);
}
if (stats.ConvertAudio > 0)
{
parts.Add("Convert audio: " + stats.ConvertAudio);
}
if (!hasRealActions)
{
return "Skip — обработка не требуется";
}
var summary = string.Join(" | ", parts
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase));
return string.IsNullOrWhiteSpace(summary) ? "Track metadata changed" : summary;
}
private static bool HasRealActions(
IReadOnlyList<string> steps,
ConversionPlanActionStats stats,
MediaAnalysisResult media,
IReadOnlyList<SidecarFile> sidecars,
ConversionProfileSettingsEntry profile,
ConversionTaskOverride? ovr,
IReadOnlyList<ExternalAudioFile>? externalAudioForBaseline)
{
if (steps.Count > 0)
{
return true;
}
if (stats is { Add: > 0 } or { Remove: > 0 } or { ConvertAudio: > 0 } or { ConvertVideo: > 0 } or { SubtitleConvert: > 0 } or { SubtitleRemove: > 0 })
{
return true;
}
if (HasTrackOverrideChanges(media, sidecars, profile, ovr, externalAudioForBaseline))
{
return true;
}
return false;
}
private static bool HasTrackOverrideChanges(
MediaAnalysisResult media,
IReadOnlyList<SidecarFile> sidecars,
ConversionProfileSettingsEntry profile,
ConversionTaskOverride? ovr,
IReadOnlyList<ExternalAudioFile>? externalAudioForBaseline)
{
if (ovr is null)
{
return false;
}
var baseline = new ConversionTaskOverride();
TrackOverrideSeeder.EnsureDefaults(baseline, media, sidecars, profile, externalAudio: externalAudioForBaseline);
return !OverridesEquivalent(ovr, baseline);
}
private static bool OverridesEquivalent(ConversionTaskOverride left, ConversionTaskOverride right)
{
if (!StringEq(left.TargetContainer, right.TargetContainer)
|| !StringEq(left.TargetVideo, right.TargetVideo)
|| !StringEq(left.TargetPixelFormat, right.TargetPixelFormat)
|| !StringEq(left.TargetResolution, right.TargetResolution)
|| !StringEq(left.TargetFps, right.TargetFps)
|| !StringEq(left.TargetAudioBitrate, right.TargetAudioBitrate)
|| !StringEq(left.TargetVideoBitrateMode, right.TargetVideoBitrateMode)
|| left.TargetVideoBitrateMbps != right.TargetVideoBitrateMbps)
{
return false;
}
var l = left.TrackOverrides.OrderBy(TrackKey).ToArray();
var r = right.TrackOverrides.OrderBy(TrackKey).ToArray();
if (l.Length != r.Length)
{
return false;
}
for (var i = 0; i < l.Length; i++)
{
if (!TrackEquivalent(l[i], r[i]))
{
return false;
}
}
return true;
}
private static bool TrackEquivalent(TrackOverrideEntry a, TrackOverrideEntry b)
{
return a.StreamIndex == b.StreamIndex
&& a.Source == b.Source
&& a.StreamKind == b.StreamKind
&& a.Action == b.Action
&& a.Default == b.Default
&& StringEq(a.ExternalPath, b.ExternalPath)
&& a.ExternalAudioStreamOrdinal == b.ExternalAudioStreamOrdinal
&& StringEq(a.ExternalStreamCodec, b.ExternalStreamCodec)
&& StringEq(a.ExternalFfprobeTitle, b.ExternalFfprobeTitle)
&& StringEq(a.Language, b.Language)
&& StringEq(a.Title, b.Title)
&& StringEq(a.AudioBitrateKbps, b.AudioBitrateKbps);
}
private static string TrackKey(TrackOverrideEntry t) =>
$"{(int)t.Source}|{(int)t.StreamKind}|{t.StreamIndex}|{(t.ExternalPath ?? string.Empty).Trim()}|a{t.ExternalAudioStreamOrdinal}";
private static bool StringEq(string? a, string? b) =>
string.Equals((a ?? string.Empty).Trim(), (b ?? string.Empty).Trim(), StringComparison.Ordinal);
private static string? BuildVideoBitrateStep(string normalizedMode, int? targetVideoBitrateKbps)
{
if (normalizedMode == VideoBitratePolicy.Auto)
{
return null;
}
if (normalizedMode == VideoBitratePolicy.Source)
{
return "Video bitrate: source";
}
if (targetVideoBitrateKbps is not { } bitrateKbps || bitrateKbps <= 0)
{
return null;
}
return $"Video bitrate: {bitrateKbps / 1000.0:0.###} Mbps";
}
private static string DescribeTrackPlanLine(TrackOverrideEntry t, bool supportsAttachments, MediaAnalysisResult? media)
{
if (t.StreamKind == MediaStreamKind.Attachment && t.Action == TrackActionKind.Add && !supportsAttachments)
{
return "Fonts skipped: container does not support attachments";
}
if (t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Remove })
{
var codec = media?.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
return SubtitleCodecRules.IsTeletext(codec) ? "Удалить teletext subtitle" : "Remove subtitle: 1";
}
return $"{t.Action} {t.StreamKind}";
}
private static IReadOnlyList<ConversionTrackPlan> BuildTrackParts(ConversionTaskOverride? ovr, string container, MediaAnalysisResult? media)
{
if (ovr is null || ovr.TrackOverrides.Count == 0)
{
return [];
}
var supportsAttachments = SupportsAttachments(container);
var list = new List<ConversionTrackPlan>(ovr.TrackOverrides.Count);
foreach (var t in ovr.TrackOverrides)
{
var action = t.Action switch
{
TrackActionKind.Add => t.StreamKind == MediaStreamKind.Attachment && !supportsAttachments
? ConversionPlanAction.None
: t.StreamKind == MediaStreamKind.Subtitle
? ConversionPlanAction.AddExternalSubtitles
: ConversionPlanAction.AddExternalAudio,
TrackActionKind.Remove => t.StreamKind == MediaStreamKind.Subtitle
? ConversionPlanAction.RemoveNonRusSubtitles
: ConversionPlanAction.RemoveDataStreams,
TrackActionKind.Convert => t.StreamKind == MediaStreamKind.Audio
? ConversionPlanAction.ConvertAudio
: t.StreamKind == MediaStreamKind.Video
? ConversionPlanAction.ConvertVideo
: ConversionPlanAction.None,
_ => ConversionPlanAction.None
};
var desc = DescribeTrackPlanLine(t, supportsAttachments, media);
list.Add(
new ConversionTrackPlan
{
StreamIndex = t.StreamIndex,
Source = t.Source,
StreamKind = t.StreamKind,
Action = action,
Description = desc
});
}
return list;
}
private static bool WantsMkv(ConversionProfileSettingsEntry p) =>
p.Container.Equals("MKV", StringComparison.OrdinalIgnoreCase) || p.Container.Contains("Matro", StringComparison.OrdinalIgnoreCase);
private static bool WantsMp4(ConversionProfileSettingsEntry p) =>
p.Container.Equals("MP4", StringComparison.OrdinalIgnoreCase) || p.Container.Equals("M4V", StringComparison.OrdinalIgnoreCase);
private static bool SupportsAttachments(string? c) =>
!string.IsNullOrWhiteSpace(c) && (c.Contains("mkv", StringComparison.OrdinalIgnoreCase) || c.Contains("matro", StringComparison.OrdinalIgnoreCase));
private static bool IsMkvContainer(string c) => c.Contains("matro", StringComparison.Ordinal) || c.Contains("mkv", StringComparison.Ordinal) || c.Contains("webm", StringComparison.Ordinal);
private static bool IsMp4ish(string c) => c.Contains("mp4", StringComparison.Ordinal) || c.Contains("mov", StringComparison.Ordinal) || c.Contains("isom", StringComparison.Ordinal);
private static bool IsUnchanged(string? s) =>
string.IsNullOrWhiteSpace(s) || s.Contains("без", StringComparison.OrdinalIgnoreCase) || s.Contains("No change", StringComparison.OrdinalIgnoreCase);
private static string ToPixNorm(string p) => p.Replace(" ", string.Empty, StringComparison.Ordinal);
private static ConversionProfileSettingsEntry MergeProfile(ConversionProfileSettingsEntry profile, ConversionTaskOverride? ovr)
{
if (ovr is null)
{
return profile;
}
static string Coa(string? a, string b) => string.IsNullOrWhiteSpace(a) ? b : a!.Trim();
return profile with
{
Container = Coa(ovr.TargetContainer, profile.Container),
Video = Coa(ovr.TargetVideo, profile.Video),
PixelFormat = Coa(ovr.TargetPixelFormat, profile.PixelFormat),
Resolution = Coa(ovr.TargetResolution, profile.Resolution),
Fps = Coa(ovr.TargetFps, profile.Fps),
Bitrate = Coa(ovr.TargetAudioBitrate, profile.Bitrate),
VideoBitrateMode = Coa(ovr.TargetVideoBitrateMode, profile.VideoBitrateMode),
VideoBitrateMbps = ovr.TargetVideoBitrateMbps > 0 ? ovr.TargetVideoBitrateMbps : profile.VideoBitrateMbps
};
}
private static bool StringEqualsLoose(string? a, string? b)
{
if (a is null || b is null)
{
return false;
}
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase) ||
a.Replace(" ", string.Empty, StringComparison.Ordinal).Equals(b.Replace(" ", string.Empty, StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase);
}
private static bool VideoCodecMatches(string? fileCodec, string profileVideo)
{
if (string.IsNullOrEmpty(fileCodec))
{
return false;
}
if (profileVideo.Contains("Copy", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var f = fileCodec.ToLowerInvariant();
if (IsUnchanged(profileVideo))
{
return true;
}
if (profileVideo.Contains("H.264", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("H264", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("avc", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("h264", StringComparison.Ordinal) || f.Contains("avc", StringComparison.Ordinal) || f == "h264" || f == "h.264";
}
if (profileVideo.Contains("H.265", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("h265", StringComparison.Ordinal) || f.Contains("hevc", StringComparison.Ordinal);
}
return f.Contains(profileVideo, StringComparison.OrdinalIgnoreCase);
}
private static bool AudioCodecMatchesAgainstLabel(string? fileCodec, string profileAudio)
{
if (string.IsNullOrEmpty(fileCodec))
{
return true;
}
if (profileAudio.Contains("Copy", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (IsUnchanged(profileAudio))
{
return true;
}
var f = fileCodec.ToLowerInvariant();
if (profileAudio.Contains("AAC", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("aac", StringComparison.Ordinal) || f.Contains("fdk", StringComparison.Ordinal);
}
if (profileAudio.Contains("AC-3", StringComparison.Ordinal) || profileAudio.Contains("AC3", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("ac3", StringComparison.Ordinal) || f == "eac3";
}
if (profileAudio.Contains("EAC3", StringComparison.OrdinalIgnoreCase) || profileAudio.Contains("E-AC-3", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("eac3", StringComparison.Ordinal) || f.Contains("ac3", StringComparison.Ordinal);
}
if (profileAudio.Contains("Opus", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("opus", StringComparison.Ordinal);
}
if (profileAudio.Contains("MP3", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("mp3", StringComparison.Ordinal);
}
if (profileAudio.Contains("FLAC", StringComparison.OrdinalIgnoreCase))
{
return f.Contains("flac", StringComparison.Ordinal);
}
return f.Contains(profileAudio, StringComparison.OrdinalIgnoreCase);
}
private static bool ResMatchesFile(int w, int h, string pRes)
{
if (TryParseMaxValue(pRes) is { } maxRes)
{
// "Максимум 1080p": ограничиваем вертикальное измерение (короткая сторона кадра).
var sourceVertical = System.Math.Min(w, h);
return sourceVertical <= maxRes;
}
if (pRes.Contains("1080", StringComparison.Ordinal))
{
return w == 1920 && h == 1080;
}
if (pRes.Contains("720", StringComparison.Ordinal))
{
return w == 1280 && h == 720;
}
if (pRes.Contains("2160", StringComparison.Ordinal) || pRes.Contains("4K", StringComparison.OrdinalIgnoreCase))
{
return w == 3840 && h == 2160;
}
var m = pRes.Split('x', 'X');
if (m.Length == 2 && int.TryParse(m[0], out var pw) && int.TryParse(m[1], out var ph))
{
return w == pw && h == ph;
}
return true;
}
private static double? TryParseMaxValue(string s)
{
if (string.IsNullOrWhiteSpace(s))
{
return null;
}
if (double.TryParse(s.Replace(',', '.').Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
{
return d;
}
var normalized = s.Trim();
var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
for (var i = parts.Length - 1; i >= 0; i--)
{
var token = parts[i]
.Replace("p", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("fps", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
if (double.TryParse(token.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out d))
{
return d;
}
}
return null;
}
private static string FormatNumber(double value) =>
value % 1d == 0d
? ((int)value).ToString(CultureInfo.InvariantCulture)
: value.ToString("0.###", CultureInfo.InvariantCulture);
private const double FpsEpsilon = 0.01;
private static void AppendSubtitlePlanSteps(MediaAnalysisResult media, ConversionTaskOverride ovr, ConversionProfileSettingsEntry profile, List<string> steps)
{
var effective = string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer;
var removed = ovr.TrackOverrides
.Where(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Remove })
.ToList();
if (removed.Count > 0)
{
var tel = 0;
var other = 0;
foreach (var t in removed)
{
var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
if (SubtitleCodecRules.IsTeletext(codec))
{
tel++;
}
else
{
other++;
}
}
if (tel > 0)
{
steps.Add("Удалить teletext subtitle");
}
if (other > 0)
{
steps.Add($"Remove subtitle: {other}");
}
}
if (!SubtitleCodecRules.TargetsMp4(effective))
{
return;
}
var transcodeSubs = ovr.TrackOverrides
.Where(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle }
&& t.Action is TrackActionKind.Convert or TrackActionKind.Keep)
.Where(t =>
{
var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
return SubtitleCodecRules.Mp4RequiresSubtitleTranscode(codec);
}).ToList();
if (transcodeSubs.Count > 0)
{
steps.Add("Subtitle -> mov_text (MP4)");
}
}
private static bool ProfileRemovesNonRusSubs(ConversionProfileSettingsEntry p) =>
p.Subtitles is "только" or "RUS" or "rus" || p.Profile.Contains("rus", StringComparison.OrdinalIgnoreCase);
private static bool IsAacCodec(string? codecName) =>
!string.IsNullOrWhiteSpace(codecName)
&& codecName.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,24 @@
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public static class ConversionProfileMapping
{
/// <summary>Запасной профиль, если имя в очереди не найдено в списке.</summary>
public static ConversionProfileSettingsEntry EmbyFallback { get; } = new()
{
Profile = "Emby",
Container = "MKV",
Video = "H.264",
PixelFormat = "yuv420p",
Resolution = "Без изменений",
Fps = "Без изменений",
Audio = "AAC",
Bitrate = "256 kbps",
VideoBitrateMode = VideoBitratePolicy.Auto,
Subtitles = "Да",
ExternalTracks = "Да",
ExternalSubtitles = "Да",
Fonts = "Да"
};
}

View File

@ -0,0 +1,281 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Сохранение и загрузка очереди конвертации (.conv_setup, JSON).</summary>
public static class ConversionQueueSetupPersistence
{
public const string FileExtension = ".conv_setup";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public static string GetQueueSetupsDirectory()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EmbyToolbox",
"QueueSetups");
Directory.CreateDirectory(dir);
return dir;
}
public static string AllocateAutoSavePath()
{
var stamp = DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss", System.Globalization.CultureInfo.InvariantCulture);
return Path.Combine(GetQueueSetupsDirectory(), $"conversion-setup-{stamp}{FileExtension}");
}
public static void SaveToPath(string path, ConversionQueueSetupRoot document)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(document, JsonOptions);
File.WriteAllText(path, json, System.Text.UTF8Encoding.UTF8);
}
public static ConversionQueueSetupRoot LoadFromPath(string path)
{
var json = File.ReadAllText(path, System.Text.UTF8Encoding.UTF8);
var doc = JsonSerializer.Deserialize<ConversionQueueSetupRoot>(json, JsonOptions)
?? throw new InvalidDataException("Пустой или некорректный .conv_setup");
return doc;
}
}
public sealed class ConversionQueueSetupRoot
{
public int SchemaVersion { get; set; } = 1;
public DateTime SavedAtUtc { get; set; }
public string? DefaultQueueProfile { get; set; }
public bool CopyPreviousTrackSettings { get; set; }
public bool DisableSubtitleDefault { get; set; }
public ConversionFormOptionsSnapshot FormOptions { get; set; } = new();
public List<ConversionProfileSettingsEntry> Profiles { get; set; } = [];
public List<ConversionQueueTaskPersistModel> Tasks { get; set; } = [];
}
public sealed class ConversionFormOptionsSnapshot
{
public List<string> ContainerOptions { get; set; } = [];
public List<string> VideoCodecOptions { get; set; } = [];
public List<string> PixelFormatOptions { get; set; } = [];
public List<string> ResolutionOptions { get; set; } = [];
public List<string> FpsOptions { get; set; } = [];
public List<string> AudioBitrateKbps { get; set; } = [];
public List<string> VideoBitrateModeOptions { get; set; } = [];
}
public sealed class ConversionQueueTaskPersistModel
{
public string FullPath { get; set; } = "";
public string? SnapshotScopeBatchRoot { get; set; }
public int OrderNumber { get; set; }
public string Profile { get; set; } = "Emby";
public string PlanSummary { get; set; } = "";
public string Status { get; set; } = ConversionQueueStatus.Pending;
public int Progress { get; set; }
public bool IsManuallyEdited { get; set; }
public bool IsProcessed { get; set; }
public bool ProcessedInCurrentRun { get; set; }
public string? LastRunId { get; set; }
public string? ErrorMessage { get; set; }
public string? ErrorDetails { get; set; }
public int FileSizeMb { get; set; }
public bool HasFfprobeAudioSummary { get; set; }
public int FfprobeAudioCount { get; set; }
public int? FfprobeAudioSizeMb { get; set; }
public bool FfprobeAudioSizePartial { get; set; }
public MediaAnalysisResult? MediaAnalysis { get; set; }
public List<SidecarFilePersistModel>? Sidecars { get; set; }
public List<ExternalAudioFilePersistModel>? ExternalAudioFiles { get; set; }
public ConversionTaskOverridePersistModel? Overrides { get; set; }
}
public sealed class SidecarFilePersistModel
{
public string FullPath { get; set; } = "";
public bool IsAudio { get; set; }
public bool IsSubtitle { get; set; }
public bool IsFont { get; set; }
public static SidecarFilePersistModel From(SidecarFile s) =>
new()
{
FullPath = s.FullPath,
IsAudio = s.IsAudio,
IsSubtitle = s.IsSubtitle,
IsFont = s.IsFont
};
public SidecarFile ToModel() => new(FullPath, IsAudio, IsSubtitle, IsFont);
}
public sealed class ExternalAudioStreamPersistModel
{
public string FileFullPath { get; set; } = "";
public int StreamOrdinal { get; set; }
public string CodecName { get; set; } = "?";
public string? TitleFromProbe { get; set; }
public int? Channels { get; set; }
public int? SampleRateHz { get; set; }
public long? BitRateBps { get; set; }
public static ExternalAudioStreamPersistModel From(ExternalAudioStream s) =>
new()
{
FileFullPath = s.FileFullPath,
StreamOrdinal = s.StreamOrdinal,
CodecName = s.CodecName,
TitleFromProbe = s.TitleFromProbe,
Channels = s.Channels,
SampleRateHz = s.SampleRateHz,
BitRateBps = s.BitRateBps
};
public ExternalAudioStream ToModel() =>
new()
{
FileFullPath = FileFullPath,
StreamOrdinal = StreamOrdinal,
CodecName = CodecName,
TitleFromProbe = TitleFromProbe,
Channels = Channels,
SampleRateHz = SampleRateHz,
BitRateBps = BitRateBps
};
}
public sealed class ExternalAudioFilePersistModel
{
public string FullPath { get; set; } = "";
public List<ExternalAudioStreamPersistModel> Streams { get; set; } = [];
public static ExternalAudioFilePersistModel From(ExternalAudioFile f) =>
new()
{
FullPath = f.FullPath,
Streams = f.Streams.Select(ExternalAudioStreamPersistModel.From).ToList()
};
public ExternalAudioFile ToModel() =>
new(FullPath, Streams.Select(s => s.ToModel()).ToList());
}
public sealed class ConversionTaskOverridePersistModel
{
public string TargetContainer { get; set; } = "";
public string TargetVideo { get; set; } = "";
public string TargetPixelFormat { get; set; } = "";
public string TargetResolution { get; set; } = "";
public string TargetFps { get; set; } = "";
public string TargetAudioBitrate { get; set; } = "256 kbps";
public string TargetVideoBitrateMode { get; set; } = VideoBitratePolicy.Auto;
public double? TargetVideoBitrateMbps { get; set; }
public List<TrackOverrideEntryPersistModel> TrackOverrides { get; set; } = [];
public static ConversionTaskOverridePersistModel From(ConversionTaskOverride o)
{
var m = new ConversionTaskOverridePersistModel
{
TargetContainer = o.TargetContainer,
TargetVideo = o.TargetVideo,
TargetPixelFormat = o.TargetPixelFormat,
TargetResolution = o.TargetResolution,
TargetFps = o.TargetFps,
TargetAudioBitrate = o.TargetAudioBitrate,
TargetVideoBitrateMode = o.TargetVideoBitrateMode,
TargetVideoBitrateMbps = o.TargetVideoBitrateMbps
};
foreach (var t in o.TrackOverrides)
{
m.TrackOverrides.Add(TrackOverrideEntryPersistModel.From(t));
}
return m;
}
public void ApplyTo(ConversionTaskOverride target)
{
target.TargetContainer = TargetContainer;
target.TargetVideo = TargetVideo;
target.TargetPixelFormat = TargetPixelFormat;
target.TargetResolution = TargetResolution;
target.TargetFps = TargetFps;
target.TargetAudioBitrate = TargetAudioBitrate;
target.TargetVideoBitrateMode = TargetVideoBitrateMode;
target.TargetVideoBitrateMbps = TargetVideoBitrateMbps;
target.TrackOverrides.Clear();
foreach (var t in TrackOverrides)
{
target.TrackOverrides.Add(t.ToEntry());
}
}
}
public sealed class TrackOverrideEntryPersistModel
{
public int StreamIndex { get; set; }
public string? ExternalPath { get; set; }
public SourceKind Source { get; set; }
public MediaStreamKind StreamKind { get; set; }
public TrackActionKind Action { get; set; }
public bool? Default { get; set; }
public string? Language { get; set; }
public string? Title { get; set; }
public string? AudioBitrateKbps { get; set; }
public int ExternalAudioStreamOrdinal { get; set; }
public string? ExternalStreamCodec { get; set; }
public string? ExternalStreamDetails { get; set; }
public int SameFileExternalAudioStreamCount { get; set; } = 1;
public string? ExternalFfprobeTitle { get; set; }
public static TrackOverrideEntryPersistModel From(TrackOverrideEntry t) =>
new()
{
StreamIndex = t.StreamIndex,
ExternalPath = t.ExternalPath,
Source = t.Source,
StreamKind = t.StreamKind,
Action = t.Action,
Default = t.Default,
Language = t.Language,
Title = t.Title,
AudioBitrateKbps = t.AudioBitrateKbps,
ExternalAudioStreamOrdinal = t.ExternalAudioStreamOrdinal,
ExternalStreamCodec = t.ExternalStreamCodec,
ExternalStreamDetails = t.ExternalStreamDetails,
SameFileExternalAudioStreamCount = t.SameFileExternalAudioStreamCount,
ExternalFfprobeTitle = t.ExternalFfprobeTitle
};
public TrackOverrideEntry ToEntry() =>
new()
{
StreamIndex = StreamIndex,
ExternalPath = ExternalPath,
Source = Source,
StreamKind = StreamKind,
Action = Action,
Default = Default,
Language = Language,
Title = Title,
AudioBitrateKbps = AudioBitrateKbps,
ExternalAudioStreamOrdinal = ExternalAudioStreamOrdinal,
ExternalStreamCodec = ExternalStreamCodec,
ExternalStreamDetails = ExternalStreamDetails,
SameFileExternalAudioStreamCount = SameFileExternalAudioStreamCount,
ExternalFfprobeTitle = ExternalFfprobeTitle
};
}

View File

@ -0,0 +1,65 @@
using System.IO;
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class ExternalFileCleanupService
{
public IReadOnlyList<string> MoveUsedToUseless(string sourceVideoPath, IReadOnlyList<SidecarFile> usedFiles)
{
if (usedFiles.Count == 0)
{
return [];
}
// Шрифты считаются переиспользуемыми ресурсами каталога и не перемещаются.
var movable = usedFiles
.Where(f => !f.IsFont)
.DistinctBy(x => x.FullPath)
.ToList();
if (movable.Count == 0)
{
return [];
}
var dir = Path.GetDirectoryName(sourceVideoPath);
if (string.IsNullOrWhiteSpace(dir))
{
return [];
}
var useless = Path.Combine(dir, "useless");
Directory.CreateDirectory(useless);
var moved = new List<string>();
foreach (var f in movable)
{
if (!File.Exists(f.FullPath))
{
continue;
}
var target = BuildUniqueTarget(useless, Path.GetFileName(f.FullPath));
File.Move(f.FullPath, target, overwrite: false);
moved.Add(target);
}
return moved;
}
private static string BuildUniqueTarget(string dir, string fileName)
{
var name = Path.GetFileNameWithoutExtension(fileName);
var ext = Path.GetExtension(fileName);
var candidate = Path.Combine(dir, fileName);
var i = 1;
while (File.Exists(candidate))
{
candidate = Path.Combine(dir, $"{name}_{i}{ext}");
i++;
}
return candidate;
}
}

View File

@ -0,0 +1,172 @@
using System.Globalization;
using System.IO;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Имена выходных файлов и аргументы ffmpeg для извлечения аудио/субтитров/вложений (stream copy).</summary>
public sealed class ExtractCommandBuilder
{
/// <summary>
/// Безопасный фрагмент имени без расширения для префикса выходных файлов (<c>Movie</c> из <c>Movie.mkv</c>).
/// </summary>
public static string SanitizeSourceFileStem(string? fileNameWithoutExtension)
{
if (string.IsNullOrWhiteSpace(fileNameWithoutExtension))
{
return "media";
}
var sb = new StringBuilder(fileNameWithoutExtension.Trim().Length);
foreach (var ch in fileNameWithoutExtension.Trim())
{
sb.Append(ch is < '\u0020' or '"' or '*' or ':' or '<' or '>' or '?' or '\\' or '/' or '|' ? '_' : ch);
}
var s = sb.ToString().Trim('.', ' ');
return string.IsNullOrEmpty(s) ? "media" : s[..Math.Min(s.Length, 200)];
}
/// <summary>Базовое имя файла (без пути): все результаты в общих каталогах, префикс — имя исходника без расширения.</summary>
public string ResolveOutputBaseFileName(string sourceStemSanitized, MediaStreamInfo stream, int ordinalInKind)
{
return stream.Kind switch
{
MediaStreamKind.Audio =>
$"{sourceStemSanitized}_audio_{ordinalInKind:D2}_{SanitizeLangSegment(stream.Language)}.{GetAudioExtension(stream.CodecName)}",
MediaStreamKind.Subtitle =>
$"{sourceStemSanitized}_subtitle_{ordinalInKind:D2}_{SanitizeLangSegment(stream.Language)}.{GetSubtitleExtension(stream.CodecName)}",
MediaStreamKind.Attachment =>
$"{sourceStemSanitized}_attachment_{ordinalInKind:D2}_{ResolveAttachmentLeafFileName(stream)}",
MediaStreamKind.Video or MediaStreamKind.Data =>
throw new InvalidOperationException("Видео и data-потоки не извлекаются."),
_ =>
throw new InvalidOperationException($"Неподдерживаемый тип потока: {stream.Kind}"),
};
}
public IReadOnlyList<string> BuildFfmpegArgumentList(string inputPath, MediaStreamInfo stream, string destinationFullPath)
{
return new[]
{
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
inputPath,
"-map",
$"0:{stream.Index}",
"-c",
"copy",
destinationFullPath,
};
}
private static string GetAudioExtension(string codecRaw)
{
var c = NormalizeCodec(codecRaw);
return c switch
{
"aac" => "aac",
"ac3" => "ac3",
"dts" => "dts",
"flac" => "flac",
"truehd" => "thd",
"eac3" => "eac3",
"opus" => "opus",
"mp3" => "mp3",
"vorbis" => "ogg",
_ => "bin",
};
}
private static string GetSubtitleExtension(string codecRaw)
{
var c = NormalizeCodec(codecRaw);
return c switch
{
"subrip" or "srt" => "srt",
"ass" or "ssa" => "ass",
"mov_text" or "text" or "tx3g" => "txt",
"hdmv_pgs_subtitle" or "pgssub" => "sup",
"dvd_subtitle" or "vobsub" => "sub",
_ => "bin",
};
}
private static string ResolveAttachmentLeafFileName(MediaStreamInfo stream)
{
var declared = stream.AttachmentDeclaredFileName?.Trim();
if (string.IsNullOrEmpty(declared))
{
declared = $"blob_{stream.Index}";
}
declared = SanitizeDeclaredAttachmentFileName(declared);
if (!Path.HasExtension(declared))
{
var mimeExt = MimeToExtension(stream.AttachmentDeclaredMimeType);
if (!string.IsNullOrEmpty(mimeExt))
{
declared += mimeExt.StartsWith('.') ? mimeExt : "." + mimeExt;
}
}
return declared;
}
private static string? MimeToExtension(string? mimeRaw)
{
if (string.IsNullOrWhiteSpace(mimeRaw))
{
return null;
}
var m = mimeRaw.Trim().ToLowerInvariant();
return m switch
{
"font/ttf" or "application/x-truetype-font" => ".ttf",
"font/otf" or "application/font-sfnt" => ".otf",
"application/vnd.ms-opentype" => ".otf",
_ => null,
};
}
private static string SanitizeDeclaredAttachmentFileName(string declared)
{
var cleaned = declared.Replace('\\', '_').Replace('/', '_');
cleaned = Path.GetFileName(cleaned);
var sb = new StringBuilder(cleaned.Length);
foreach (var ch in cleaned)
{
sb.Append(ch is < '\u0020' or '"' or '*' or ':' or '<' or '>' or '?' or '|' ? '_' : ch);
}
cleaned = sb.ToString().Trim('.', '_');
return string.IsNullOrEmpty(cleaned) ? "attachment" : cleaned;
}
private static string SanitizeLangSegment(string? lang)
{
if (string.IsNullOrWhiteSpace(lang))
{
return "und";
}
var s = lang.Trim().ToLowerInvariant();
Span<char> buffer = stackalloc char[s.Length];
for (var i = 0; i < s.Length; i++)
{
var ch = s[i];
buffer[i] = ch is '_' or '-' or >= 'a' and <= 'z' ? ch : '_';
}
var span = new string(buffer).Trim('_');
return string.IsNullOrEmpty(span) ? "und" : span[..Math.Min(span.Length, 24)];
}
private static string NormalizeCodec(string? codecRaw) =>
string.IsNullOrWhiteSpace(codecRaw) ? string.Empty : codecRaw.Trim().ToLowerInvariant();
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,207 @@
using System.Diagnostics;
using System.IO;
using System.Text;
namespace EmbyToolbox.Services;
public sealed record VideoEncoderSettings(string Codec, string ExtraArguments);
public sealed class FfmpegEncoderDiscoveryService
{
private readonly object _gate = new();
private HashSet<string>? _cachedEncoders;
public HashSet<string> GetAvailableEncoders(LoggingService logging)
{
lock (_gate)
{
if (_cachedEncoders is not null)
{
return _cachedEncoders;
}
_cachedEncoders = DiscoverEncoders(logging);
return _cachedEncoders;
}
}
public VideoEncoderSettings ResolveVideoEncoder(
string selectedMode,
string targetVideo,
bool autoFallbackToCpu,
LoggingService logging)
{
var encoders = GetAvailableEncoders(logging);
var family = ResolveTargetFamily(targetVideo);
if (string.Equals(selectedMode, HardwareAccelerationMode.Auto, StringComparison.OrdinalIgnoreCase))
{
var auto = TryResolveByPriority(encoders, family);
if (auto is not null)
{
logging.Info($"выбран энкодер (Auto): {auto.Codec}", "conversion.hw");
return auto;
}
var cpu = BuildCpuSettings(family);
logging.Warning("аппаратные энкодеры не найдены, fallback на CPU", "conversion.hw");
return cpu;
}
var preferred = TryResolveSpecificMode(selectedMode, encoders, family);
if (preferred is not null)
{
logging.Info($"выбран энкодер ({selectedMode}): {preferred.Codec}", "conversion.hw");
return preferred;
}
logging.Error($"выбранный энкодер недоступен: режим={selectedMode}, target={targetVideo}", "conversion.hw");
if (!autoFallbackToCpu)
{
throw new InvalidOperationException($"Недоступен выбранный энкодер: {selectedMode}");
}
var fallback = BuildCpuSettings(family);
logging.Warning($"fallback на CPU: {fallback.Codec}", "conversion.hw");
return fallback;
}
private static VideoEncoderSettings? TryResolveByPriority(HashSet<string> encoders, string family)
{
return TryResolveModeInternal(HardwareAccelerationMode.Nvenc, encoders, family)
?? TryResolveModeInternal(HardwareAccelerationMode.Qsv, encoders, family)
?? TryResolveModeInternal(HardwareAccelerationMode.Amf, encoders, family);
}
private static VideoEncoderSettings? TryResolveSpecificMode(string selectedMode, HashSet<string> encoders, string family)
{
if (string.Equals(selectedMode, HardwareAccelerationMode.Cpu, StringComparison.OrdinalIgnoreCase))
{
return BuildCpuSettings(family);
}
return TryResolveModeInternal(selectedMode, encoders, family);
}
private static VideoEncoderSettings? TryResolveModeInternal(string mode, HashSet<string> encoders, string family)
{
var codec = family == "h265"
? mode.ToUpperInvariant() switch
{
"NVENC" => "hevc_nvenc",
"QSV" => "hevc_qsv",
"AMF" => "hevc_amf",
_ => string.Empty
}
: mode.ToUpperInvariant() switch
{
"NVENC" => "h264_nvenc",
"QSV" => "h264_qsv",
"AMF" => "h264_amf",
_ => string.Empty
};
if (string.IsNullOrWhiteSpace(codec) || !encoders.Contains(codec))
{
return null;
}
var extra = mode.ToUpperInvariant() switch
{
"NVENC" => "-preset p5 -cq 23 -b:v 0",
"QSV" => "-global_quality 23",
"AMF" => "-quality quality -rc cqp -qp_i 23 -qp_p 23",
_ => string.Empty
};
return new VideoEncoderSettings(codec, extra);
}
private static VideoEncoderSettings BuildCpuSettings(string family)
{
var codec = family == "h265" ? "libx265" : "libx264";
return new VideoEncoderSettings(codec, "-preset medium -crf 23");
}
private static string ResolveTargetFamily(string targetVideo)
{
if (targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase) || targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase))
{
return "h265";
}
return "h264";
}
private static HashSet<string> DiscoverEncoders(LoggingService logging)
{
var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe");
if (!File.Exists(ffmpegPath))
{
logging.Error($"не найден ffmpeg: {ffmpegPath}", "conversion.hw");
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var start = new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = "-hide_banner -encoders",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = new Process { StartInfo = start };
process.Start();
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
var text = string.Concat(stdout, Environment.NewLine, stderr);
var found = ParseEncoderNames(text);
logging.Info(
$"обнаружено энкодеров ffmpeg: {found.Count}. hw: {FormatHwSummary(found)}",
"conversion.hw",
command: $"{ffmpegPath} -hide_banner -encoders",
stdout: text);
return found;
}
private static HashSet<string> ParseEncoderNames(string text)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var t = line.TrimStart();
if (!t.StartsWith('V') && !t.StartsWith("V.", StringComparison.Ordinal))
{
continue;
}
var parts = t.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
result.Add(parts[1].Trim());
}
}
return result;
}
private static string FormatHwSummary(HashSet<string> encoders)
{
var known = new[]
{
"h264_nvenc", "hevc_nvenc",
"h264_qsv", "hevc_qsv",
"h264_amf", "hevc_amf",
"libx264", "libx265"
};
var present = known.Where(encoders.Contains).ToArray();
return present.Length == 0 ? "none" : string.Join(", ", present);
}
}

View File

@ -0,0 +1,386 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed record FfmpegProgressSnapshot(int? Percent, bool IsIndeterminate, string StatusText);
public sealed record FfmpegRunResult(bool Success, string StdErr, int ExitCode);
/// <summary>
/// Запускает ffmpeg с <c>-progress pipe:1 -nostats</c>: асинхронно читает stdout (прогресс) и stderr
/// (обязательно, чтобы не заблокировался процесс), не блокируя UI.
/// </summary>
public sealed class FfmpegService
{
private const int MinProgressReportIntervalMs = 300;
private const string StatusRunning = "В работе";
public async Task<FfmpegRunResult> RunAsync(
FfmpegCommand command,
MediaAnalysisResult? media,
IProgress<FfmpegProgressSnapshot>? progress,
CancellationToken cancellationToken)
{
var start = new ProcessStartInfo
{
FileName = command.Executable,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
foreach (var a in command.ArgumentList)
{
start.ArgumentList.Add(a);
}
using var p = new Process { StartInfo = start };
p.Start();
// stderr обязан читаться параллельно с progress на stdout, иначе буфер заполняется и процесс встанет
var stderrV = p.StandardError.ReadToEndAsync(cancellationToken);
using (cancellationToken.Register(
static state =>
{
try
{
if (state is Process proc && !proc.HasExited)
{
proc.Kill(true);
}
}
catch
{
// ignore
}
},
p))
{
try
{
var totalDurationMs = ResolveTotalDurationMs(media, out var totalFrameEstimate);
await ReadProgressFromStdoutAsync(
p,
totalDurationMs,
totalFrameEstimate,
progress,
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
}
await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
string errText;
try
{
errText = await stderrV.ConfigureAwait(false);
}
catch
{
errText = string.Empty;
}
return new FfmpegRunResult(p.ExitCode == 0, errText, p.ExitCode);
}
/// <summary>
/// <paramref name="totalFrameEstimate"/> — только для fallback, если в прогресс-стриме нет времени.
/// </summary>
private static double? ResolveTotalDurationMs(MediaAnalysisResult? media, out double? totalFrameEstimate)
{
totalFrameEstimate = null;
if (media is null)
{
return null;
}
var sec = media.GetEffectiveDurationSeconds();
if (sec is not { } d || d <= 0)
{
return null;
}
var totalMs = d * 1000.0;
var fps = media.PrimaryVideo?.FrameRate;
if (fps is { } f && f > 0)
{
totalFrameEstimate = f * d;
}
return totalMs;
}
private static async Task ReadProgressFromStdoutAsync(
Process p,
double? totalDurationMs,
double? totalFrameEstimate,
IProgress<FfmpegProgressSnapshot>? progress,
CancellationToken cancellationToken)
{
var outReader = p.StandardOutput;
var lastReportedPercent = -1;
var lastReportTime = long.MinValue;
var anyTimeProgress = false;
string? line;
while ((line = await outReader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
if (line.Equals("progress=end", StringComparison.OrdinalIgnoreCase))
{
// 100% фазы кодирования (снаружи маппится 090% очереди)
lastReportedPercent = 100;
progress?.Report(new FfmpegProgressSnapshot(100, false, StatusRunning));
continue;
}
if (TryParseTimeProgressMs(line, totalDurationMs, out var outMs) && totalDurationMs is { } tdm && tdm > 0)
{
anyTimeProgress = true;
var pct = (int)Math.Clamp(outMs / tdm * 100.0, 0, 100);
if (ShouldReportThrottled(pct, false, lastReportedPercent, lastReportTime, out lastReportTime))
{
lastReportedPercent = pct;
progress?.Report(new FfmpegProgressSnapshot(pct, false, StatusRunning));
}
continue;
}
if (!anyTimeProgress &&
totalDurationMs is not null && totalFrameEstimate is { } tfe && tfe > 0 &&
line.StartsWith("frame=", StringComparison.Ordinal) &&
TryGetFrameCount(line, out var frame) && frame >= 0)
{
var pct = (int)Math.Clamp(frame / tfe * 100.0, 0, 100);
if (ShouldReportThrottled(pct, false, lastReportedPercent, lastReportTime, out lastReportTime))
{
lastReportedPercent = pct;
progress?.Report(new FfmpegProgressSnapshot(pct, false, StatusRunning));
}
}
else if (totalDurationMs is null && line.StartsWith("frame=", StringComparison.Ordinal))
{
if (lastReportedPercent < 0)
{
// редко: только кадры без длительности
if (ShouldReportThrottled(0, true, lastReportedPercent, lastReportTime, out lastReportTime))
{
lastReportedPercent = 0;
progress?.Report(new FfmpegProgressSnapshot(null, true, StatusRunning));
}
}
}
}
// если ни одной time-строки не было — индетерминат, один раз
if (lastReportedPercent < 0 && !anyTimeProgress)
{
progress?.Report(new FfmpegProgressSnapshot(null, true, StatusRunning));
}
}
private static bool ShouldReportThrottled(
int newPercent,
bool force,
int lastReported,
long lastTimeMs,
out long newLastTime)
{
newLastTime = lastTimeMs;
if (force)
{
newLastTime = Environment.TickCount64;
return true;
}
var now = Environment.TickCount64;
if (lastTimeMs == long.MinValue || now - lastTimeMs >= MinProgressReportIntervalMs || newPercent > lastReported + 4)
{
newLastTime = now;
return true;
}
return false;
}
/// <summary>
/// out_time_ms / out_time_us / out_time. Значения из key=value по документации ffmpeg: *_ms часто = микросекунды; *_us = микросекунды.
/// </summary>
private static bool TryParseTimeProgressMs(string line, double? totalDurationMs, out double outTimeMs)
{
outTimeMs = 0;
if (line.StartsWith("out_time_ms=", StringComparison.Ordinal))
{
return TryParseOutTimeMsField(line["out_time_ms=".Length..], totalDurationMs, out outTimeMs);
}
if (line.StartsWith("out_time_us=", StringComparison.Ordinal))
{
return TryParseOutTimeUsField(line["out_time_us=".Length..], out outTimeMs);
}
if (line.StartsWith("out_time=", StringComparison.Ordinal))
{
return TryParseOutTimeString(line["out_time=".Length..], out outTimeMs);
}
return false;
}
/// <summary>Значение out_time_ms: встречается и как мс, и как мкс; выбираем согласованно с <paramref name="totalDurationMs"/>.</summary>
private static bool TryParseOutTimeMsField(string raw, double? totalDurationMs, out double outTimeMs)
{
outTimeMs = 0;
var s = raw.AsSpan().Trim();
if (s.Length == 0 || s.Equals("N/A", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n))
{
return false;
}
if (n < 0)
{
return false;
}
var asTrueMs = n;
var asFromMicro = n / 1000.0;
if (totalDurationMs is { } t && t > 0)
{
var leeway = 5000.0;
if (asTrueMs <= t + leeway)
{
outTimeMs = asTrueMs;
return true;
}
if (asFromMicro <= t + leeway)
{
outTimeMs = asFromMicro;
return true;
}
}
outTimeMs = asFromMicro;
return true;
}
private static bool TryParseOutTimeUsField(string raw, out double outTimeMs)
{
outTimeMs = 0;
var s = raw.AsSpan().Trim();
if (s.Length == 0 || s.Equals("N/A", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n))
{
return false;
}
outTimeMs = n / 1000.0; // микросекунды → мс
return outTimeMs >= 0;
}
private static bool TryParseOutTimeString(string raw, out double outTimeMs)
{
outTimeMs = 0;
var t = raw.AsSpan().Trim();
if (t.Length == 0 || t.Equals("N/A", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (t.Contains(':'))
{
// HH:MM:SS[.fract] или 00:00:01.00
var c = 0;
for (var i = 0; i < t.Length; i++)
{
if (t[i] == ':')
{
c++;
}
}
if (c == 2)
{
var p1 = t.IndexOf(':');
var p2 = t[(p1 + 1)..].IndexOf(':') + p1 + 1;
if (p1 > 0 && p2 > p1 &&
int.TryParse(t[..p1], out var h) &&
int.TryParse(t[(p1 + 1)..p2], out var m) &&
double.TryParse(t[(p2 + 1)..], NumberStyles.Any, CultureInfo.InvariantCulture, out var sec))
{
outTimeMs = (h * 3600.0 + m * 60.0 + sec) * 1000.0;
return outTimeMs >= 0;
}
}
}
if (double.TryParse(t, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
{
outTimeMs = seconds * 1000.0;
return outTimeMs >= 0;
}
return false;
}
private static bool TryGetFrameCount(string line, out int frame)
{
frame = 0;
if (!line.StartsWith("frame=", StringComparison.Ordinal))
{
return false;
}
var i = 6;
while (i < line.Length && char.IsWhiteSpace(line[i]))
{
i++;
}
if (i >= line.Length)
{
return false;
}
var f = 0;
var any = false;
for (; i < line.Length; i++)
{
var c = line[i];
if (!char.IsDigit(c))
{
break;
}
f = f * 10 + (c - '0');
any = true;
}
if (!any)
{
return false;
}
frame = f;
return true;
}
}

View File

@ -0,0 +1,171 @@
using System.Globalization;
using System.Text.Json;
namespace EmbyToolbox.Services;
/// <summary>
/// Извлечение количества аудиодорожек и оценка суммарного размера аудио по JSON ffprobe.
/// </summary>
public static class FfprobeAudioInfoParser
{
public static FfprobeAudioInfo? TryParse(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(json);
return ParseRoot(doc.RootElement);
}
catch
{
return null;
}
}
private static FfprobeAudioInfo? ParseRoot(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
{
return null;
}
double? formatDurationSec = null;
if (root.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
{
formatDurationSec = TryReadDurationSeconds(format);
}
if (!root.TryGetProperty("streams", out var streams) || streams.ValueKind != JsonValueKind.Array)
{
return new FfprobeAudioInfo(0, null, false);
}
var audioList = new List<JsonElement>();
foreach (var stream in streams.EnumerateArray())
{
if (stream.ValueKind == JsonValueKind.Object &&
stream.TryGetProperty("codec_type", out var ct) &&
ct.ValueKind == JsonValueKind.String &&
string.Equals(ct.GetString(), "audio", StringComparison.OrdinalIgnoreCase))
{
audioList.Add(stream);
}
}
var count = audioList.Count;
if (count == 0)
{
return new FfprobeAudioInfo(0, 0, false);
}
var partial = false;
var totalBytes = 0.0;
var anySize = false;
foreach (var stream in audioList)
{
var duration = TryReadStreamDurationSeconds(stream) ?? formatDurationSec;
var bps = TryReadBitrateBps(stream);
if (duration is null || bps is null)
{
partial = true;
continue;
}
if (double.IsNaN(duration.Value) || double.IsInfinity(duration.Value) || duration.Value <= 0)
{
partial = true;
continue;
}
if (bps.Value <= 0)
{
partial = true;
continue;
}
totalBytes += duration.Value * (bps.Value / 8.0);
anySize = true;
}
int? sizeMb;
if (!anySize)
{
sizeMb = null;
}
else
{
var mb = totalBytes / (1024.0 * 1024.0);
sizeMb = (int)Math.Round(mb, MidpointRounding.AwayFromZero);
}
return new FfprobeAudioInfo(count, sizeMb, partial);
}
private static double? TryReadDurationSeconds(JsonElement el)
{
if (el.TryGetProperty("duration", out var d) && d.ValueKind == JsonValueKind.String)
{
if (double.TryParse(d.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var s))
{
return s;
}
}
return null;
}
private static double? TryReadStreamDurationSeconds(JsonElement stream)
{
return TryReadDurationSeconds(stream);
}
private static long? TryReadBitrateBps(JsonElement stream)
{
if (stream.TryGetProperty("bit_rate", out var br) && br.ValueKind == JsonValueKind.String)
{
var s = br.GetString();
if (long.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var b) && b > 0)
{
return b;
}
}
if (stream.TryGetProperty("max_bit_rate", out var mbr) && mbr.ValueKind == JsonValueKind.String)
{
var s = mbr.GetString();
if (long.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var b) && b > 0)
{
return b;
}
}
if (stream.TryGetProperty("tags", out var tags) && tags.ValueKind == JsonValueKind.Object)
{
foreach (var p in tags.EnumerateObject())
{
if (p.Name.Contains("BPS", StringComparison.OrdinalIgnoreCase) &&
p.Value.ValueKind is JsonValueKind.String or JsonValueKind.Number)
{
if (p.Value.ValueKind == JsonValueKind.String && long.TryParse(p.Value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var b) && b > 0)
{
return b;
}
if (p.Value.ValueKind == JsonValueKind.Number && p.Value.TryGetInt64(out var n) && n > 0)
{
return n;
}
}
}
}
return null;
}
}
public readonly record struct FfprobeAudioInfo(int AudioStreamCount, int? AudioSizeMbTotal, bool IsPartial);

View File

@ -0,0 +1,145 @@
using System.Diagnostics;
using System.IO;
using System.Text;
namespace EmbyToolbox.Services;
public sealed class FfprobeService
{
public async Task<FfprobeResult> AnalyzeAsync(string filePath, CancellationToken cancellationToken = default)
=> await AnalyzeInternalAsync(filePath, "-v error -show_format -show_streams -show_chapters -print_format json", cancellationToken);
public async Task<FfprobeResult> AnalyzeSubtitlePacketsAsync(string filePath, CancellationToken cancellationToken = default)
=> await AnalyzeInternalAsync(
filePath,
"-v error -select_streams s -show_packets -show_entries packet=stream_index,duration_time -print_format json",
cancellationToken);
private async Task<FfprobeResult> AnalyzeInternalAsync(string filePath, string argumentPrefix, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
return FfprobeResult.Fail("Файл не выбран или не существует.");
}
var ffprobePath = ResolveFfprobePath();
if (!File.Exists(ffprobePath))
{
return FfprobeResult.Fail($"ffprobe не найден: {ffprobePath}");
}
var arguments = $"{argumentPrefix} \"{filePath}\"";
var startInfo = new ProcessStartInfo
{
FileName = ffprobePath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = new Process { StartInfo = startInfo };
process.Start();
using var killRegistration = cancellationToken.Register(
static state =>
{
try
{
if (state is not Process p || p.HasExited)
{
return;
}
p.Kill(entireProcessTree: true);
}
catch
{
// ignore
}
},
process,
useSynchronizationContext: false);
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
try
{
await Task.WhenAll(outputTask, errorTask);
}
catch
{
// ignore
}
throw;
}
var output = await outputTask;
var error = await errorTask;
if (process.ExitCode != 0)
{
var message = string.IsNullOrWhiteSpace(error)
? $"ffprobe завершился с кодом {process.ExitCode}."
: error.Trim();
return FfprobeResult.Fail(message, $"{ffprobePath} {arguments}", output, error);
}
if (string.IsNullOrWhiteSpace(output))
{
return FfprobeResult.Fail("ffprobe вернул пустой JSON-результат.", $"{ffprobePath} {arguments}", output, error);
}
return FfprobeResult.Ok(output, $"{ffprobePath} {arguments}", output, error);
}
private static string ResolveFfprobePath()
{
return Path.Combine(AppContext.BaseDirectory, "Tools", "ffprobe.exe");
}
}
public sealed class FfprobeResult
{
private FfprobeResult(bool isSuccess, string json, string error, string command, string stdOut, string stdErr)
{
IsSuccess = isSuccess;
Json = json;
Error = error;
Command = command;
StdOut = stdOut;
StdErr = stdErr;
}
public bool IsSuccess { get; }
public string Json { get; }
public string Error { get; }
public string Command { get; }
public string StdOut { get; }
public string StdErr { get; }
public static FfprobeResult Ok(string json, string command, string stdOut, string stdErr)
{
return new FfprobeResult(true, json, string.Empty, command, stdOut, stdErr);
}
public static FfprobeResult Fail(string error, string command = "", string stdOut = "", string stdErr = "")
{
return new FfprobeResult(false, string.Empty, error, command, stdOut, stdErr);
}
}

View File

@ -0,0 +1,115 @@
using System.IO;
using System.Linq;
namespace EmbyToolbox.Services;
public sealed class FileDiscoveryService
{
/// <summary>Стабильная сортировка списка видеофайлов по полному нормализованному пути (без учёта регистра).</summary>
public static StringComparer QueuePathOrderComparer { get; } = StringComparer.OrdinalIgnoreCase;
/// <summary>
/// Собирает пути к поддерживаемым видео: отдельные файлы, из каталогов — рекурсивно, без дублей.
/// </summary>
public IReadOnlyList<string> CollectVideoFilesFromFileSystemEntries(IEnumerable<string>? entryPaths, Action<string>? onError = null)
{
if (entryPaths is null)
{
return [];
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entryPaths)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var full = Path.GetFullPath(entry);
if (File.Exists(full))
{
if (SupportedVideoFormats.IsSupportedVideoFile(full))
{
set.Add(full);
}
continue;
}
if (Directory.Exists(full))
{
foreach (var file in DiscoverVideoFiles(full, onError))
{
set.Add(file);
}
}
}
return SortVideoPathsByFullPath(set);
}
/// <summary>Возвращает новый список путей, отсортированный по <see cref="QueuePathOrderComparer"/> (стабильно).</summary>
public static IReadOnlyList<string> SortVideoPathsByFullPath(IEnumerable<string> paths) =>
paths.OrderBy(p => p, QueuePathOrderComparer).ToList();
public IReadOnlyList<string> DiscoverVideoFiles(string rootDirectory, Action<string>? onError = null)
{
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
{
return [];
}
var result = new List<string>();
var pending = new Stack<string>();
pending.Push(rootDirectory);
while (pending.Count > 0)
{
var current = pending.Pop();
try
{
foreach (var file in Directory.EnumerateFiles(current))
{
if (!SupportedVideoFormats.IsSupportedVideoFile(file))
{
continue;
}
string normalized;
try
{
normalized = Path.GetFullPath(file);
}
catch
{
continue;
}
result.Add(normalized);
}
}
catch (Exception ex)
{
onError?.Invoke($"ошибка чтения каталога '{current}': {ex.Message}");
}
try
{
foreach (var childDirectory in Directory.EnumerateDirectories(current))
{
pending.Push(childDirectory);
}
}
catch (Exception ex)
{
onError?.Invoke($"ошибка чтения вложенных каталогов '{current}': {ex.Message}");
}
}
return SortVideoPathsByFullPath(result);
}
public bool IsSupportedVideoFile(string path) => SupportedVideoFormats.IsSupportedVideoFile(path);
}

View File

@ -0,0 +1,329 @@
using System.Globalization;
using System.Linq;
using System.Text.Json;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class ForcedSubtitleDetector
{
private const int ForcedEventsThreshold = 200;
private const double ForcedCoverageThreshold = 0.10;
private static readonly string[] MarkerTokens =
[
"forced",
"force",
"форс",
"форсированные",
"signs",
"songs",
"signs & songs",
"надписи"
];
private readonly FfprobeService _ffprobe;
private readonly LoggingService _logging;
public ForcedSubtitleDetector(FfprobeService ffprobe, LoggingService logging)
{
_ffprobe = ffprobe;
_logging = logging;
}
public bool IsForcedSubtitle(MediaStreamInfo track, double? mediaDuration) =>
!string.IsNullOrWhiteSpace(DetectReason(track, mediaDuration));
public string? DetectReason(MediaStreamInfo track, double? mediaDuration)
{
if (track.Kind != MediaStreamKind.Subtitle)
{
return null;
}
var reasons = new List<string>(4);
if (track.IsForcedByDisposition)
{
reasons.Add("disposition");
}
if (ContainsMarker(track.Title))
{
reasons.Add("title");
}
if (ContainsMarker(track.FileNameTag))
{
reasons.Add("filename");
}
var stats = CalculateSubtitleStats(track, mediaDuration);
if (stats.EventCount is { } count && count < ForcedEventsThreshold)
{
reasons.Add("events");
}
if (stats.Coverage is { } coverage && coverage < ForcedCoverageThreshold)
{
reasons.Add("coverage");
}
return reasons.Count == 0 ? null : string.Join("/", reasons.Distinct(StringComparer.Ordinal));
}
public SubtitleTrackStats CalculateSubtitleStats(MediaStreamInfo track, double? mediaDuration)
{
var events = track.SubtitleEventCount;
var coverage = track.SubtitleCoverage;
if (coverage is null
&& mediaDuration is > 0
&& track.DurationSeconds is { } streamDuration
&& streamDuration > 0)
{
coverage = streamDuration / mediaDuration.Value;
}
return new SubtitleTrackStats(events, coverage);
}
public async Task<IReadOnlyDictionary<int, SubtitleTrackStats>> CalculateSubtitleStats(
string videoPath,
double? mediaDuration,
CancellationToken cancellationToken = default)
{
var probe = await _ffprobe.AnalyzeSubtitlePacketsAsync(videoPath, cancellationToken).ConfigureAwait(false);
if (!probe.IsSuccess)
{
_logging.Debug($"forced subtitle stats skipped: {probe.Error}", "subtitle.forced");
return new Dictionary<int, SubtitleTrackStats>();
}
try
{
using var doc = JsonDocument.Parse(probe.Json);
if (!doc.RootElement.TryGetProperty("packets", out var packets) || packets.ValueKind != JsonValueKind.Array)
{
return new Dictionary<int, SubtitleTrackStats>();
}
var aggregate = new Dictionary<int, SubtitleStatsAccumulator>();
foreach (var packet in packets.EnumerateArray())
{
if (!packet.TryGetProperty("stream_index", out var streamIndexRaw))
{
continue;
}
var streamIndex = ParseInt(streamIndexRaw);
if (streamIndex is null)
{
continue;
}
if (!aggregate.TryGetValue(streamIndex.Value, out var current))
{
current = new SubtitleStatsAccumulator();
}
current.EventCount++;
if (packet.TryGetProperty("duration_time", out var durationTimeRaw))
{
var duration = ParseDouble(durationTimeRaw);
if (duration is > 0)
{
current.TotalDurationSeconds += duration.Value;
}
}
aggregate[streamIndex.Value] = current;
}
var result = new Dictionary<int, SubtitleTrackStats>(aggregate.Count);
foreach (var (streamIndex, stats) in aggregate)
{
double? coverage = null;
if (mediaDuration is > 0 && stats.TotalDurationSeconds > 0)
{
coverage = stats.TotalDurationSeconds / mediaDuration.Value;
}
result[streamIndex] = new SubtitleTrackStats(stats.EventCount, coverage);
}
return result;
}
catch (Exception ex)
{
_logging.Warning($"forced subtitle stats parse failed: {ex.Message}", "subtitle.forced", ex);
return new Dictionary<int, SubtitleTrackStats>();
}
}
public async Task<MediaAnalysisResult> ApplyDetectionAsync(
string videoPath,
MediaAnalysisResult media,
CancellationToken cancellationToken = default)
{
if (media.SubtitleStreams.Count == 0)
{
return media;
}
var statsByStream = await CalculateSubtitleStats(videoPath, media.GetEffectiveDurationSeconds(), cancellationToken).ConfigureAwait(false);
var updatedAll = new List<MediaStreamInfo>(media.AllStreams.Count);
foreach (var stream in media.AllStreams)
{
if (stream.Kind != MediaStreamKind.Subtitle)
{
updatedAll.Add(stream);
continue;
}
statsByStream.TryGetValue(stream.Index, out var stats);
var withStats = new MediaStreamInfo
{
Index = stream.Index,
Kind = stream.Kind,
CodecName = stream.CodecName,
Language = stream.Language,
Title = stream.Title,
IsDefault = stream.IsDefault,
BitRateBps = stream.BitRateBps,
Profile = stream.Profile,
Encoder = stream.Encoder,
Channels = stream.Channels,
SampleRateHz = stream.SampleRateHz,
Width = stream.Width,
Height = stream.Height,
AverageFrameRate = stream.AverageFrameRate,
FrameRate = stream.FrameRate,
PixelFormat = stream.PixelFormat,
ColorSpace = stream.ColorSpace,
ColorPrimaries = stream.ColorPrimaries,
ColorTransfer = stream.ColorTransfer,
SubtitleFormat = stream.SubtitleFormat,
FileNameTag = stream.FileNameTag,
IsForcedByDisposition = stream.IsForcedByDisposition,
SubtitleEventCount = stats.EventCount,
SubtitleCoverage = stats.Coverage,
AttachmentDeclaredFileName = stream.AttachmentDeclaredFileName,
AttachmentDeclaredMimeType = stream.AttachmentDeclaredMimeType,
DurationSeconds = stream.DurationSeconds
};
var reason = DetectReason(withStats, media.GetEffectiveDurationSeconds());
var isForced = !string.IsNullOrWhiteSpace(reason);
var updated = new MediaStreamInfo
{
Index = withStats.Index,
Kind = withStats.Kind,
CodecName = withStats.CodecName,
Language = withStats.Language,
Title = withStats.Title,
IsDefault = withStats.IsDefault,
BitRateBps = withStats.BitRateBps,
Profile = withStats.Profile,
Encoder = withStats.Encoder,
Channels = withStats.Channels,
SampleRateHz = withStats.SampleRateHz,
Width = withStats.Width,
Height = withStats.Height,
AverageFrameRate = withStats.AverageFrameRate,
FrameRate = withStats.FrameRate,
PixelFormat = withStats.PixelFormat,
ColorSpace = withStats.ColorSpace,
ColorPrimaries = withStats.ColorPrimaries,
ColorTransfer = withStats.ColorTransfer,
SubtitleFormat = withStats.SubtitleFormat,
FileNameTag = withStats.FileNameTag,
IsForcedByDisposition = withStats.IsForcedByDisposition,
IsForced = isForced,
ForcedDetectionReason = reason,
SubtitleEventCount = withStats.SubtitleEventCount,
SubtitleCoverage = withStats.SubtitleCoverage,
AttachmentDeclaredFileName = withStats.AttachmentDeclaredFileName,
AttachmentDeclaredMimeType = withStats.AttachmentDeclaredMimeType,
DurationSeconds = withStats.DurationSeconds
};
if (isForced)
{
_logging.Debug($"forced subtitle detected: {BuildTrackLabel(updated)}", "subtitle.forced");
_logging.Debug($"forced detection reason: {reason}", "subtitle.forced");
}
updatedAll.Add(updated);
}
return new MediaAnalysisResult
{
ContainerFormat = media.ContainerFormat,
FormatName = media.FormatName,
FormatBitRateBps = media.FormatBitRateBps,
DurationSeconds = media.DurationSeconds,
SourceVideoBitrateBps = media.SourceVideoBitrateBps,
AllStreams = updatedAll,
VideoStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Video).ToList(),
AudioStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Audio).ToList(),
SubtitleStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Subtitle).ToList(),
DataStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Data).ToList()
};
}
private static bool ContainsMarker(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
var normalized = input.Trim().ToLowerInvariant();
return MarkerTokens.Any(normalized.Contains);
}
private static int? ParseInt(JsonElement value)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
{
return number;
}
if (value.ValueKind == JsonValueKind.String
&& int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
return null;
}
private static double? ParseDouble(JsonElement value)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number))
{
return number;
}
if (value.ValueKind == JsonValueKind.String
&& double.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
return null;
}
private static string BuildTrackLabel(MediaStreamInfo stream)
{
var title = string.IsNullOrWhiteSpace(stream.Title) ? "-" : stream.Title;
var file = string.IsNullOrWhiteSpace(stream.FileNameTag) ? "-" : stream.FileNameTag;
return $"#{stream.Index} | title={title} | file={file}";
}
private struct SubtitleStatsAccumulator
{
public int EventCount;
public double TotalDurationSeconds;
}
}
public readonly record struct SubtitleTrackStats(int? EventCount, double? Coverage);

View File

@ -0,0 +1,6 @@
namespace EmbyToolbox.Services;
public interface IProfileSettingsProvider
{
ConversionProfileSettingsEntry? GetProfile(string name);
}

View File

@ -0,0 +1,9 @@
namespace EmbyToolbox.Services;
public enum LogLevel
{
Debug = 0,
Info = 1,
Warning = 2,
Error = 3
}

View File

@ -0,0 +1,141 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Threading;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Services;
public sealed class LoggingService
{
private const int UiLogLimit = 1000;
private readonly string _logsDirectory;
public LoggingService()
{
_logsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EmbyToolbox",
"Logs");
Directory.CreateDirectory(_logsDirectory);
}
public ObservableCollection<LogEntryViewModel> UiEntries { get; } = new();
public LogLevel MinimumFileLogLevel { get; set; } = LogLevel.Info;
public string LogsDirectory => _logsDirectory;
public void Debug(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
{
Write(LogLevel.Debug, message, module, exception, command, stdout, stderr);
}
public void Info(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
{
Write(LogLevel.Info, message, module, exception, command, stdout, stderr);
}
public void Warning(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
{
Write(LogLevel.Warning, message, module, exception, command, stdout, stderr);
}
public void Error(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
{
Write(LogLevel.Error, message, module, exception, command, stdout, stderr);
}
public void ClearUi()
{
UiEntries.Clear();
}
private void Write(LogLevel level, string message, string module, Exception? exception, string? command, string? stdout, string? stderr)
{
var now = DateTime.Now;
var entry = new LogEntryViewModel
{
Timestamp = now,
Level = level,
LevelText = level.ToString(),
Module = module,
Message = message
};
AddUiEntryThreadSafe(entry);
if (level < MinimumFileLogLevel)
{
return;
}
WriteToFile(now, level, module, message, exception, command, stdout, stderr);
}
private void AddUiEntryThreadSafe(LogEntryViewModel entry)
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher is null || dispatcher.CheckAccess())
{
UiEntries.Add(entry);
while (UiEntries.Count > UiLogLimit)
{
UiEntries.RemoveAt(0);
}
return;
}
dispatcher.BeginInvoke(
DispatcherPriority.DataBind,
new Action(
() =>
{
UiEntries.Add(entry);
while (UiEntries.Count > UiLogLimit)
{
UiEntries.RemoveAt(0);
}
}));
}
private void WriteToFile(DateTime timestamp, LogLevel level, string module, string message, Exception? exception, string? command, string? stdout, string? stderr)
{
var path = Path.Combine(_logsDirectory, $"app-{timestamp:yyyy-MM-dd}.log");
var sb = new StringBuilder();
sb.Append('[').Append(timestamp.ToString("yyyy-MM-dd HH:mm:ss")).Append("] ");
sb.Append(level.ToString().ToUpperInvariant()).Append(" ");
sb.Append("module=").Append(module).Append(" ");
sb.Append("message=").Append(message);
sb.AppendLine();
if (exception is not null)
{
sb.AppendLine("exception:");
sb.AppendLine(exception.ToString());
}
if (!string.IsNullOrWhiteSpace(command))
{
sb.Append("command: ").AppendLine(command);
}
if (!string.IsNullOrWhiteSpace(stdout))
{
sb.AppendLine("stdout:");
sb.AppendLine(stdout);
}
if (!string.IsNullOrWhiteSpace(stderr))
{
sb.AppendLine("stderr:");
sb.AppendLine(stderr);
}
sb.AppendLine(new string('-', 80));
File.AppendAllText(path, sb.ToString(), Encoding.UTF8);
}
}

View File

@ -0,0 +1,380 @@
using System.Globalization;
using System.Linq;
using System.Text.Json;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Разбор JSON ffprobe в <see cref="MediaAnalysisResult"/>.</summary>
public static class MediaAnalysisParser
{
public static MediaAnalysisResult? TryParse(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(json);
return ParseRoot(doc.RootElement);
}
catch
{
return null;
}
}
private static MediaAnalysisResult ParseRoot(JsonElement root)
{
var format = string.Empty;
var name = string.Empty;
long? formatBitRateBps = null;
double? durationSec = null;
if (root.TryGetProperty("format", out var fmt) && fmt.ValueKind == JsonValueKind.Object)
{
if (fmt.TryGetProperty("format_name", out var fn) && fn.ValueKind == JsonValueKind.String)
{
format = fn.GetString() ?? string.Empty;
}
if (fmt.TryGetProperty("format_long_name", out var fl) && fl.ValueKind == JsonValueKind.String)
{
name = fl.GetString() ?? string.Empty;
}
if (fmt.TryGetProperty("duration", out var d))
{
durationSec = ParseDuration(d);
}
formatBitRateBps = ParseLong(fmt, "bit_rate");
}
var streams = new List<MediaStreamInfo>();
if (root.TryGetProperty("streams", out var st) && st.ValueKind == JsonValueKind.Array)
{
foreach (var e in st.EnumerateArray())
{
if (e.ValueKind == JsonValueKind.Object)
{
var s = TryParseStream(e);
if (s is not null)
{
streams.Add(s);
}
}
}
}
var primaryVideoBitrate = streams
.Where(x => x.Kind == MediaStreamKind.Video)
.OrderByDescending(static v => ((long)(v.Width ?? 0)) * (v.Height ?? 0))
.ThenByDescending(static v => v.IsDefault ? 1 : 0)
.Select(static v => v.BitRateBps)
.FirstOrDefault();
return new MediaAnalysisResult
{
ContainerFormat = format,
FormatName = string.IsNullOrEmpty(name) ? format : name,
FormatBitRateBps = formatBitRateBps,
DurationSeconds = durationSec,
AllStreams = streams,
VideoStreams = streams.Where(x => x.Kind == MediaStreamKind.Video).ToList(),
AudioStreams = streams.Where(x => x.Kind == MediaStreamKind.Audio).ToList(),
SubtitleStreams = streams.Where(x => x.Kind == MediaStreamKind.Subtitle).ToList(),
DataStreams = streams.Where(x => x.Kind == MediaStreamKind.Data).ToList(),
SourceVideoBitrateBps = primaryVideoBitrate ?? formatBitRateBps
};
}
private static MediaStreamInfo? TryParseStream(JsonElement s)
{
if (!s.TryGetProperty("codec_type", out var ct) || ct.ValueKind != JsonValueKind.String)
{
return null;
}
var streamDuration = GetStreamDuration(s);
var t = (ct.GetString() ?? string.Empty).ToLowerInvariant();
if (t == "video")
{
return new MediaStreamInfo
{
Index = GetInt(s, "index", -1),
Kind = MediaStreamKind.Video,
CodecName = GetStr(s, "codec_name", "?") ?? "?",
Profile = GetStr(s, "profile", null),
Encoder = GetTagExact(s, "encoder"),
Language = GetTagLang(s),
Title = GetTagTitle(s),
IsDefault = GetDisposition(s, "default", false),
Width = (int?)GetIntNullable(s, "width"),
Height = (int?)GetIntNullable(s, "height"),
AverageFrameRate = ParseFpsByKey(s, "avg_frame_rate"),
FrameRate = ParseFps(s),
PixelFormat = GetStr(s, "pix_fmt", null),
ColorSpace = GetStr(s, "color_space", null),
ColorPrimaries = GetStr(s, "color_primaries", null),
ColorTransfer = GetStr(s, "color_transfer", null),
BitRateBps = ParseLong(s, "bit_rate") ?? ParseLong(s, "max_bit_rate"),
DurationSeconds = streamDuration
};
}
if (t == "audio")
{
return new MediaStreamInfo
{
Index = GetInt(s, "index", -1),
Kind = MediaStreamKind.Audio,
CodecName = GetStr(s, "codec_name", "?") ?? "?",
Profile = GetStr(s, "profile", null),
Encoder = GetTagExact(s, "encoder"),
Language = GetTagLang(s),
Title = GetTagTitle(s),
IsDefault = GetDisposition(s, "default", false),
BitRateBps = ParseLong(s, "bit_rate") ?? ParseLong(s, "max_bit_rate"),
Channels = (int?)GetIntNullable(s, "channels"),
SampleRateHz = (int?)GetIntNullable(s, "sample_rate"),
DurationSeconds = streamDuration
};
}
if (t == "subtitle")
{
var isForcedDisposition = GetDisposition(s, "forced", false);
return new MediaStreamInfo
{
Index = GetInt(s, "index", -1),
Kind = MediaStreamKind.Subtitle,
CodecName = GetStr(s, "codec_name", "?") ?? "?",
Profile = GetStr(s, "profile", null),
Encoder = GetTagExact(s, "encoder"),
Language = GetTagLang(s),
Title = GetTagTitle(s),
IsDefault = GetDisposition(s, "default", false),
IsForcedByDisposition = isForcedDisposition,
IsForced = isForcedDisposition,
ForcedDetectionReason = isForcedDisposition ? "disposition" : null,
SubtitleFormat = GetStr(s, "codec_name", null),
FileNameTag = GetTagExact(s, "filename"),
DurationSeconds = streamDuration
};
}
if (t == "attachment")
{
return new MediaStreamInfo
{
Index = GetInt(s, "index", -1),
Kind = MediaStreamKind.Attachment,
CodecName = "attachment",
DurationSeconds = streamDuration,
AttachmentDeclaredFileName = GetTagExact(s, "filename"),
AttachmentDeclaredMimeType = GetTagExact(s, "mimetype"),
};
}
if (t == "data")
{
return new MediaStreamInfo
{
Index = GetInt(s, "index", -1),
Kind = MediaStreamKind.Data,
CodecName = GetStr(s, "codec_name", "data") ?? "data",
DurationSeconds = streamDuration
};
}
return null;
}
private static double? GetStreamDuration(JsonElement s)
{
if (!s.TryGetProperty("duration", out var d))
{
return null;
}
return ParseDuration(d);
}
private static int GetInt(JsonElement s, string name, int def)
{
if (s.TryGetProperty(name, out var n))
{
if (n.ValueKind == JsonValueKind.Number && n.TryGetInt32(out var v))
{
return v;
}
if (n.ValueKind == JsonValueKind.String && int.TryParse(n.GetString(), out var s2))
{
return s2;
}
}
return def;
}
private static int? GetIntNullable(JsonElement s, string name)
{
if (s.TryGetProperty(name, out var n) && n.ValueKind == JsonValueKind.Number)
{
return n.GetInt32();
}
if (s.TryGetProperty(name, out var s2) && s2.ValueKind == JsonValueKind.String && int.TryParse(s2.GetString(), out var p))
{
return p;
}
return null;
}
private static string? GetStr(JsonElement s, string name, string? def)
{
if (s.TryGetProperty(name, out var n) && n.ValueKind == JsonValueKind.String)
{
return n.GetString() ?? def;
}
return def;
}
private static long? ParseLong(JsonElement s, string name)
{
if (!s.TryGetProperty(name, out var p))
{
return null;
}
if (p.ValueKind == JsonValueKind.String && long.TryParse(p.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var a))
{
return a;
}
if (p.ValueKind == JsonValueKind.Number)
{
if (p.TryGetInt64(out var l))
{
return l;
}
}
return null;
}
private static string? GetTagLang(JsonElement s)
{
if (s.TryGetProperty("tags", out var t) && t.ValueKind == JsonValueKind.Object)
{
if (t.TryGetProperty("language", out var l) && l.ValueKind == JsonValueKind.String)
{
return l.GetString();
}
}
return null;
}
private static string? GetTagTitle(JsonElement s)
{
if (s.TryGetProperty("tags", out var t) && t.ValueKind == JsonValueKind.Object)
{
if (t.TryGetProperty("title", out var l) && l.ValueKind == JsonValueKind.String)
{
return l.GetString();
}
}
return null;
}
private static string? GetTagExact(JsonElement stream, string tagKey)
{
if (!stream.TryGetProperty("tags", out var tags) || tags.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!tags.TryGetProperty(tagKey, out var val) || val.ValueKind != JsonValueKind.String)
{
return null;
}
return val.GetString();
}
private static bool GetDisposition(JsonElement s, string d, bool def)
{
if (s.TryGetProperty("disposition", out var dis) && dis.ValueKind == JsonValueKind.Object)
{
if (dis.TryGetProperty(d, out var v))
{
if (v.ValueKind == JsonValueKind.Number)
{
return v.GetInt32() != 0;
}
}
}
return def;
}
private static double? ParseFps(JsonElement s)
{
return ParseFpsByKey(s, "r_frame_rate");
}
private static double? ParseFpsByKey(JsonElement s, string key)
{
if (!s.TryGetProperty(key, out var f) || f.ValueKind != JsonValueKind.String)
{
return null;
}
return ParseFpsString(f.GetString() ?? string.Empty);
}
private static double? ParseFpsString(string s)
{
if (string.IsNullOrEmpty(s) || s == "0/0")
{
return null;
}
var p = s.IndexOf('/');
if (p > 0)
{
if (int.TryParse(s.AsSpan(0, p), out var a) && int.TryParse(s.AsSpan(p + 1), out var b) && b != 0)
{
return a / (double)b;
}
}
if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
{
return d;
}
return null;
}
private static double? ParseDuration(JsonElement d)
{
if (d.ValueKind == JsonValueKind.Number)
{
return d.GetDouble();
}
if (d.ValueKind == JsonValueKind.String &&
double.TryParse(d.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{
return v;
}
return null;
}
}

View File

@ -0,0 +1,230 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class MergeService
{
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
private readonly Func<string?> _tempDirectoryProvider;
private readonly LoggingService _logging;
private readonly FfprobeService _ffprobeService;
private readonly ChapterBuilderService _chapterBuilderService;
public MergeService(
LoggingService logging,
FfprobeService ffprobeService,
ChapterBuilderService chapterBuilderService,
Func<string?>? tempDirectoryProvider = null)
{
_logging = logging;
_ffprobeService = ffprobeService;
_chapterBuilderService = chapterBuilderService;
_tempDirectoryProvider = tempDirectoryProvider ?? (() => null);
}
public async Task MergeAsync(
IReadOnlyList<MergeFileItem> orderedFiles,
string outputPath,
IProgress<int>? progress,
CancellationToken cancellationToken)
{
if (orderedFiles.Count < 2)
{
throw new InvalidOperationException("Для объединения нужно минимум 2 файла.");
}
if (string.IsNullOrWhiteSpace(outputPath))
{
throw new InvalidOperationException("Не задан путь итогового файла.");
}
var configuredTempRoot = ResolveTempRoot();
var tempDirectory = Path.Combine(configuredTempRoot, "Merge", Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
var tempOutputPath = Path.Combine(tempDirectory, $"{Path.GetFileNameWithoutExtension(outputPath)}.mkv");
Directory.CreateDirectory(tempDirectory);
try
{
_logging.Info($"объединение: подготовка файлов ({orderedFiles.Count}), temp={tempDirectory}", "merge");
var durations = new List<double>(orderedFiles.Count);
foreach (var file in orderedFiles)
{
durations.Add(await ResolveDurationSecondsAsync(file.FullPath, cancellationToken).ConfigureAwait(false));
}
var totalDurationMs = Math.Max(1.0, durations.Sum() * 1000.0);
var chapters = orderedFiles.Select(f => f.PartName).ToList();
var metadataText = _chapterBuilderService.BuildFfmetadata(chapters, durations);
var concatListPath = Path.Combine(tempDirectory, "merge-list.txt");
var metadataPath = Path.Combine(tempDirectory, "chapters.ffmeta");
await File.WriteAllTextAsync(concatListPath, BuildConcatList(orderedFiles), Utf8NoBom, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(metadataPath, metadataText, Utf8NoBom, cancellationToken).ConfigureAwait(false);
var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe");
if (!File.Exists(ffmpegPath))
{
throw new FileNotFoundException($"ffmpeg не найден: {ffmpegPath}");
}
var args = $"-hide_banner -y -progress pipe:1 -nostats -f concat -safe 0 -i \"{concatListPath}\" -i \"{metadataPath}\" -map 0 -map_metadata 1 -c copy \"{tempOutputPath}\"";
_logging.Info($"объединение: запуск ffmpeg (temp output) -> {tempOutputPath}", "merge", command: $"{ffmpegPath} {args}");
var runResult = await RunFfmpegAsync(ffmpegPath, args, totalDurationMs, progress, cancellationToken).ConfigureAwait(false);
if (runResult.ExitCode != 0)
{
throw new InvalidOperationException($"ffmpeg завершился с кодом {runResult.ExitCode}: {runResult.StdErr}");
}
if (!File.Exists(tempOutputPath))
{
throw new IOException("Временный итоговый файл не был создан.");
}
progress?.Report(94);
await MoveTempOutputToFinalPathAsync(tempOutputPath, outputPath, cancellationToken).ConfigureAwait(false);
progress?.Report(98);
_logging.Info($"объединение завершено успешно: {outputPath}", "merge");
progress?.Report(100);
}
finally
{
try
{
if (Directory.Exists(tempDirectory))
{
Directory.Delete(tempDirectory, recursive: true);
}
}
catch
{
// ignore temp cleanup errors
}
}
}
private static Task MoveTempOutputToFinalPathAsync(string tempOutputPath, string finalOutputPath, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(Path.GetDirectoryName(finalOutputPath)!);
if (File.Exists(finalOutputPath))
{
File.Delete(finalOutputPath);
}
File.Move(tempOutputPath, finalOutputPath);
return Task.CompletedTask;
}
private string ResolveTempRoot()
{
var configured = _tempDirectoryProvider.Invoke();
if (!string.IsNullOrWhiteSpace(configured))
{
return configured.Trim();
}
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EmbyToolbox",
"Temp");
}
private async Task<double> ResolveDurationSecondsAsync(string filePath, CancellationToken cancellationToken)
{
var probeResult = await _ffprobeService.AnalyzeAsync(filePath, cancellationToken).ConfigureAwait(false);
if (!probeResult.IsSuccess)
{
throw new InvalidOperationException($"ffprobe не смог получить длительность для '{filePath}': {probeResult.Error}");
}
var parsed = MediaAnalysisParser.TryParse(probeResult.Json);
var duration = parsed?.GetEffectiveDurationSeconds();
if (duration is not { } d || d <= 0.001)
{
throw new InvalidOperationException($"Не удалось определить длительность файла: {filePath}");
}
return d;
}
private static string BuildConcatList(IEnumerable<MergeFileItem> files)
{
var sb = new StringBuilder();
foreach (var file in files)
{
var escaped = file.FullPath.Replace("'", "'\\''", StringComparison.Ordinal);
sb.Append("file '").Append(escaped).AppendLine("'");
}
return sb.ToString();
}
private static async Task<(int ExitCode, string StdErr)> RunFfmpegAsync(
string executable,
string arguments,
double totalDurationMs,
IProgress<int>? progress,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = executable,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using var process = new Process { StartInfo = startInfo };
process.Start();
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
using var killRegistration = cancellationToken.Register(
static state =>
{
try
{
if (state is Process p && !p.HasExited)
{
p.Kill(entireProcessTree: true);
}
}
catch
{
// ignore
}
},
process,
useSynchronizationContext: false);
string? line;
while ((line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
if (!line.StartsWith("out_time_ms=", StringComparison.Ordinal))
{
continue;
}
var raw = line["out_time_ms=".Length..];
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var outTimeMicro))
{
continue;
}
var percent = (int)Math.Clamp((outTimeMicro / 1000.0) / totalDurationMs * 100.0, 0.0, 100.0);
progress?.Report(percent);
}
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var stdErr = await stderrTask.ConfigureAwait(false);
return (process.ExitCode, stdErr);
}
}

View File

@ -0,0 +1,58 @@
using System.IO;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>MPEG-TS remux: генерация PTS и fallback при ошибках mux.</summary>
public static class MpegTsTimestampHelpers
{
/// <summary>Входной файл распознан как MPEG Transport Stream (.ts / m2ts или format_name mpegts).</summary>
public static bool IsMpegTsInput(MediaAnalysisResult? media, string? fileName)
{
if (media is null)
{
return false;
}
var blob = ((media.ContainerFormat ?? string.Empty) + "," + (media.FormatName ?? string.Empty)).ToLowerInvariant();
if (blob.Contains("mpegts", StringComparison.Ordinal)
|| blob.Contains("mpeg-ts", StringComparison.Ordinal)
|| blob.Contains("m2ts", StringComparison.Ordinal))
{
return true;
}
if (!string.IsNullOrEmpty(fileName))
{
var ext = Path.GetExtension(fileName);
if (ext.Equals(".ts", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".m2ts", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".mts", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary>Сообщения ffmpeg при copy/remux без валидных PTS.</summary>
public static bool LooksLikeTimestampMuxFailure(string? stderr)
{
if (string.IsNullOrWhiteSpace(stderr))
{
return false;
}
var s = stderr.AsSpan();
return ContainsLoose(s, "unknown timestamp")
|| ContainsLoose(s, "Timestamps are unset")
|| ContainsLoose(s, "unset in a packet")
|| ContainsLoose(s, "Can't write packet with unknown timestamp");
}
private static bool ContainsLoose(ReadOnlySpan<char> haystack, string needle)
{
return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
}
}

View File

@ -0,0 +1,78 @@
using System.Collections;
using System.Globalization;
namespace EmbyToolbox.Services;
public sealed class NaturalStringComparer : IComparer<string>, IComparer
{
public static readonly NaturalStringComparer Instance = new();
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y))
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var ix = 0;
var iy = 0;
while (ix < x.Length && iy < y.Length)
{
var cx = x[ix];
var cy = y[iy];
if (char.IsDigit(cx) && char.IsDigit(cy))
{
var startX = ix;
var startY = iy;
while (ix < x.Length && char.IsDigit(x[ix])) ix++;
while (iy < y.Length && char.IsDigit(y[iy])) iy++;
var partX = x[startX..ix].TrimStart('0');
var partY = y[startY..iy].TrimStart('0');
if (partX.Length != partY.Length)
{
return partX.Length.CompareTo(partY.Length);
}
var compareNumeric = string.Compare(partX, partY, StringComparison.Ordinal);
if (compareNumeric != 0)
{
return compareNumeric;
}
var rawX = x[startX..ix];
var rawY = y[startY..iy];
if (rawX.Length != rawY.Length)
{
return rawX.Length.CompareTo(rawY.Length);
}
}
else
{
var cmp = char.ToUpper(cx, CultureInfo.InvariantCulture).CompareTo(char.ToUpper(cy, CultureInfo.InvariantCulture));
if (cmp != 0)
{
return cmp;
}
ix++;
iy++;
}
}
return x.Length.CompareTo(y.Length);
}
int IComparer.Compare(object? x, object? y) => Compare(x as string, y as string);
}

View File

@ -0,0 +1,251 @@
using System.Globalization;
using System.Media;
using System.Runtime.Versioning;
using System.Windows.Threading;
using Windows.Data.Xml.Dom;
using Windows.UI.Notifications;
using EmbyToolbox.Interop;
namespace EmbyToolbox.Services;
/// <summary>Звуковые и toast-уведомления после обработки очереди конвертации.</summary>
public sealed class NotificationService
{
/// <summary>Должен совпадать с SetCurrentProcessExplicitAppUserModelID при старте приложения.</summary>
public const string ToastAppUserModelId = "EmbyToolbox.Desktop";
private readonly LoggingService _logging;
private readonly Func<bool> _soundPref;
private readonly Func<bool> _toastPref;
private readonly Dispatcher? _dispatcher;
public NotificationService(
LoggingService logging,
Func<bool> notifyCompletionSoundAfterQueueEnabled,
Func<bool> notifyWindowsToastAfterQueueEnabled,
Dispatcher? uiDispatcher)
{
_logging = logging;
_soundPref = notifyCompletionSoundAfterQueueEnabled;
_toastPref = notifyWindowsToastAfterQueueEnabled;
_dispatcher = uiDispatcher;
LogAppUserModelRegistrationState();
}
private void LogAppUserModelRegistrationState()
{
if (string.Equals(AppUserModelIdRegistration.LastRegisteredId, ToastAppUserModelId, StringComparison.Ordinal))
{
_logging.Info($"App User Model ID: {ToastAppUserModelId}", "notify");
return;
}
if (!string.IsNullOrWhiteSpace(AppUserModelIdRegistration.LastDiagnostics))
{
_logging.Warning(
$"Windows toast недоступен: не удалось зарегистрировать AppUserModelID ({AppUserModelIdRegistration.LastDiagnostics})",
"notify");
return;
}
_logging.Warning("Windows toast недоступен: статус регистрации AppUserModelID неизвестен", "notify");
}
public void NotifyQueueCompleted(int successCount, int errorCount)
{
successCount = Math.Max(0, successCount);
errorCount = Math.Max(0, errorCount);
QueueOnUiIdle(
() =>
{
PlayQueueCompletionSound(successCount, errorCount);
string body;
if (errorCount > 0)
{
body = string.Format(
CultureInfo.CurrentCulture,
"Обработка завершена с ошибками. Успешно: {0}, ошибок: {1}.",
successCount,
errorCount);
}
else
{
body = string.Format(
CultureInfo.CurrentCulture,
"Обработка завершена. Файлов обработано: {0}.",
successCount);
}
TryShowWindowsToastInternal("Emby Toolbox", body, respectToastPreference: true, contextHint: null);
});
}
public void NotifyQueueCancelled()
{
QueueOnUiIdle(
() => TryShowWindowsToastInternal(
"Emby Toolbox",
"Обработка остановлена пользователем.",
respectToastPreference: true,
contextHint: null));
}
public void PlayCompletionSound(bool hasErrors)
{
QueueOnUiIdle(
() =>
{
if (!_soundPref())
{
_logging.Info("уведомления отключены в настройках (звук)", "notify");
return;
}
try
{
ResolveCompletionSound(hasErrors ? 2 : 0).Play();
_logging.Info("звук уведомления о завершении очереди воспроизведён", "notify");
}
catch (Exception ex)
{
_logging.Warning($"ошибка воспроизведения звука: {ex.Message}", "notify");
}
});
}
/// <summary>Кнопка «Проверить уведомление»: звук и toast вне зависимости от флагов уведомлений.</summary>
public void ShowSettingsTestNotification()
{
QueueOnUiIdle(
() =>
{
try
{
SystemSounds.Asterisk.Play();
_logging.Info("тест: воспроизведён звук Asterisk", "notify");
}
catch (Exception ex)
{
_logging.Warning($"ошибка тестового звука: {ex.Message}", "notify");
}
TryShowWindowsToastInternal(
"Emby Toolbox",
"Тестовое уведомление",
respectToastPreference: false,
contextHint: "тест из настроек");
});
}
private static SystemSound ResolveCompletionSound(int kind) =>
kind switch
{
1 => SystemSounds.Exclamation,
>= 2 => SystemSounds.Hand,
_ => SystemSounds.Asterisk
};
private void QueueOnUiIdle(Action work)
{
if (_dispatcher is { HasShutdownStarted: false })
{
_dispatcher.BeginInvoke(work, DispatcherPriority.ApplicationIdle);
}
else
{
try
{
work();
}
catch (Exception ex)
{
_logging.Warning($"уведомление: ошибка без UI dispatcher — {ex.Message}", "notify");
}
}
}
private void PlayQueueCompletionSound(int successCount, int errorCount)
{
if (!_soundPref())
{
_logging.Info("уведомления отключены в настройках (звук)", "notify");
return;
}
try
{
var kind = errorCount <= 0 ? 0 : successCount > 0 ? 1 : 2;
ResolveCompletionSound(kind).Play();
_logging.Info("звук уведомления о завершении очереди воспроизведён", "notify");
}
catch (Exception ex)
{
_logging.Warning($"ошибка воспроизведения звука: {ex.Message}", "notify");
}
}
private void TryShowWindowsToastInternal(string title, string body, bool respectToastPreference, string? contextHint)
{
if (respectToastPreference && !_toastPref())
{
_logging.Info("уведомления отключены в настройках", "notify");
return;
}
var ctx = string.IsNullOrWhiteSpace(contextHint) ? string.Empty : $" ({contextHint})";
_logging.Info($"попытка показать toast: «{EscapeForLog(title)}»{ctx}", "notify");
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 10240))
{
_logging.Warning("Windows toast недоступен: требуется Windows 10 (10240) или новее", "notify");
return;
}
try
{
ShowWindowsToastCore(title, body);
_logging.Info("toast успешно отправлен", "notify");
}
catch (Exception ex)
{
_logging.Warning($"ошибка toast: {ex.Message}", "notify", ex);
_logging.Warning($"Windows toast недоступен: {ex.Message}", "notify");
}
}
private static string EscapeForLog(string s) =>
s.Replace("\r\n", " ", StringComparison.Ordinal).Trim();
[SupportedOSPlatform("windows10.0.10240.0")]
private static void ShowWindowsToastCore(string title, string body)
{
var xmlPayload =
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>" +
"<toast>" +
"<visual><binding template=\"ToastGeneric\">" +
"<text id=\"1\">" + EscapeXml(title) + "</text>" +
"<text id=\"2\">" + EscapeXml(body) + "</text>" +
"</binding></visual>" +
"</toast>";
var doc = new XmlDocument();
doc.LoadXml(xmlPayload);
var toast = new ToastNotification(doc);
toast.ExpirationTime = DateTimeOffset.UtcNow.AddMinutes(30);
var notifier = ToastNotificationManager.CreateToastNotifier(ToastAppUserModelId);
notifier.Show(toast);
}
private static string EscapeXml(string s)
{
return s.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal)
.Replace("\"", "&quot;", StringComparison.Ordinal)
.Replace("'", "&apos;", StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,15 @@
using System;
namespace EmbyToolbox.Services;
public sealed class ProfileSettingsProvider : IProfileSettingsProvider
{
private readonly Func<string, ConversionProfileSettingsEntry?> _resolve;
public ProfileSettingsProvider(Func<string, ConversionProfileSettingsEntry?> resolve)
{
_resolve = resolve;
}
public ConversionProfileSettingsEntry? GetProfile(string name) => _resolve(name);
}

View File

@ -0,0 +1,325 @@
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Ход пакетного анализа очереди (ffprobe) для IProgress и UI.</summary>
public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount);
/// <summary>
/// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по <see cref="CancellationToken"/>,
/// обновление строки через <paramref name="uiInvoke"/> (UI).
/// </summary>
public sealed class QueueAnalysisService
{
private const int MaxParallel = 2;
private readonly FfprobeService _ffprobe;
private readonly LoggingService _logging;
private readonly SidecarDiscoveryService _sidecar;
private readonly ConversionPlanService _planService;
private readonly IProfileSettingsProvider _profile;
public QueueAnalysisService(
FfprobeService ffprobe,
LoggingService logging,
SidecarDiscoveryService sidecar,
ConversionPlanService planService,
IProfileSettingsProvider profile)
{
_ffprobe = ffprobe;
_logging = logging;
_sidecar = sidecar;
_planService = planService;
_profile = profile;
}
public async Task<int> RunAsync(
IReadOnlyList<ConversionQueueItem> items,
Func<ConversionQueueItem, bool> isStillInQueue,
bool autoRemoveForeignTracks,
bool disableSubtitleDefault,
IProgress<QueueAnalysisProgress>? progress,
Func<Action, Task> uiInvoke,
CancellationToken cancellationToken)
{
if (items.Count == 0)
{
return 0;
}
var total = items.Count;
var lockObj = new object();
var state = new ProgressState();
var sem = new SemaphoreSlim(MaxParallel, MaxParallel);
var tasks = items
.Select(
item => ProcessOneAsync(
item,
isStillInQueue,
progress,
uiInvoke,
autoRemoveForeignTracks,
disableSubtitleDefault,
sem,
lockObj,
total,
state,
cancellationToken))
.ToArray();
await Task.WhenAll(tasks).ConfigureAwait(false);
return state.ErrorCount;
}
private async Task ProcessOneAsync(
ConversionQueueItem item,
Func<ConversionQueueItem, bool> isStillInQueue,
IProgress<QueueAnalysisProgress>? progress,
Func<Action, Task> uiInvoke,
bool autoRemoveForeignTracks,
bool disableSubtitleDefault,
SemaphoreSlim sem,
object lockObj,
int total,
ProgressState state,
CancellationToken cancellationToken)
{
var acquired = false;
var itemAnalysisFailed = false;
try
{
try
{
await sem.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
return;
}
acquired = true;
if (cancellationToken.IsCancellationRequested)
{
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
return;
}
FfprobeResult result;
try
{
result = await _ffprobe.AnalyzeAsync(item.FullPath, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
return;
}
if (cancellationToken.IsCancellationRequested)
{
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
return;
}
SidecarDiscoveryResult discovery;
try
{
discovery = await _sidecar.DiscoverAsync(item.FullPath, _ffprobe, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
return;
}
catch (Exception ex)
{
_logging.Error($"поиск sidecar: {ex.Message} — {item.FullPath}", "conversion.sidecar", ex);
discovery = new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
}
var media = result.IsSuccess ? MediaAnalysisParser.TryParse(result.Json) : null;
var failed = false;
await uiInvoke(
() =>
{
if (!isStillInQueue(item))
{
return;
}
if (item.Status != ConversionQueueStatus.Analyzing)
{
return;
}
if (!result.IsSuccess)
{
failed = true;
item.PlanSummary = "Ошибка анализа";
item.Status = ConversionQueueStatus.Error;
item.Progress = 0;
item.ErrorMessage =
string.IsNullOrWhiteSpace(result.Error) ? "Ошибка ffprobe." : result.Error.Trim();
item.ErrorDetails =
string.IsNullOrWhiteSpace(result.StdErr) ? null : result.StdErr.Trim();
_logging.Error($"ffprobe: {result.Error} — {item.FullPath}", "conversion.ffprobe");
return;
}
if (media is null)
{
failed = true;
item.PlanSummary = "Ошибка анализа";
item.Status = ConversionQueueStatus.Error;
item.Progress = 0;
item.ErrorMessage = "Не удалось разобрать ответ ffprobe (неверный JSON).";
item.ErrorDetails = !string.IsNullOrWhiteSpace(result.StdErr)
? result.StdErr.Trim()
: (string.IsNullOrWhiteSpace(result.Json) ? null : result.Json.Trim());
_logging.Error($"ffprobe: неверный JSON (media) — {item.FullPath}", "conversion.ffprobe");
return;
}
var audio = FfprobeAudioInfoParser.TryParse(result.Json) ?? new FfprobeAudioInfo(0, null, true);
var side = discovery.Sidecars;
var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
// При повторном анализе sidecar-набор может измениться (добавили/удалили внешние файлы).
// Пересобираем список дорожек, чтобы не держать устаревшие external entries.
item.TaskOverride.TrackOverrides.Clear();
TrackOverrideSeeder.EnsureDefaults(
item.TaskOverride,
media,
side,
profile,
autoRemoveForeignTracks,
discovery.ExternalAudioFiles,
item.FullPath,
sidecarTitleResolver: null,
logging: _logging,
disableSubtitleDefault: disableSubtitleDefault);
if (autoRemoveForeignTracks)
{
var removedForeign = item.TaskOverride.TrackOverrides.Count(t =>
t.Source == SourceKind.Embedded
&& t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle
&& t.Action == TrackActionKind.Remove
&& IsForeignLanguageForAutoRemove(t.Language));
if (removedForeign > 0)
{
_logging.Info($"автоудаление иностранных дорожек: {removedForeign} ({item.FullPath})", "conversion.queue");
}
}
var plan = _planService.Build(media, side, profile, item.TaskOverride, discovery.ExternalAudioFiles);
item.SetSuccessfulMediaAnalysis(
media,
side,
discovery.ExternalAudioFiles,
plan,
audio.AudioStreamCount,
audio.AudioSizeMbTotal,
audio.IsPartial);
item.Status = ConversionQueueStatus.Pending;
item.Progress = 0;
})
.ConfigureAwait(false);
if (failed)
{
itemAnalysisFailed = true;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
itemAnalysisFailed = true;
_logging.Error($"ffprobe очереди: {ex.Message}", "conversion.ffprobe", ex);
await uiInvoke(
() =>
{
if (!isStillInQueue(item) || item.Status != ConversionQueueStatus.Analyzing)
{
return;
}
item.PlanSummary = "Ошибка анализа";
item.Status = ConversionQueueStatus.Error;
item.Progress = 0;
item.ErrorMessage = ex.Message;
item.ErrorDetails = null;
})
.ConfigureAwait(false);
}
finally
{
int p;
int e;
lock (lockObj)
{
state.ProcessedCount++;
if (itemAnalysisFailed)
{
state.ErrorCount++;
}
p = state.ProcessedCount;
e = state.ErrorCount;
}
progress?.Report(new QueueAnalysisProgress(p, total, e));
if (acquired)
{
sem.Release();
}
}
}
private static async Task MarkCancelledOnUiAsync(
ConversionQueueItem item,
Func<ConversionQueueItem, bool> isStillInQueue,
Func<Action, Task> uiInvoke)
{
await uiInvoke(
() =>
{
if (!isStillInQueue(item))
{
return;
}
if (item.Status != ConversionQueueStatus.Analyzing)
{
return;
}
item.PlanSummary = "Анализ отменён";
item.Status = ConversionQueueStatus.Cancelled;
item.Progress = 0;
})
.ConfigureAwait(false);
}
private sealed class ProgressState
{
public int ProcessedCount;
public int ErrorCount;
}
private static bool IsForeignLanguageForAutoRemove(string? language)
{
if (string.IsNullOrWhiteSpace(language))
{
return false;
}
var lang = language.Trim().ToLowerInvariant();
if (lang is "und" or "unknown" or "?")
{
return false;
}
return lang is not ("rus" or "ru");
}
}

View File

@ -0,0 +1,248 @@
using System.IO;
namespace EmbyToolbox.Services;
/// <summary>
/// Сценарии для запоминания последних каталогов в диалогах открытия/сохранения.
/// </summary>
public enum RecentPathScenario
{
SeriesRenamer,
ConversionAddFiles,
ConversionAddFolder,
Merge,
VideoInfoOpenFile,
SettingsTempFolder,
SettingsOutputFolder,
TrackExtractDestination
}
/// <summary>
/// Запоминает последние каталоги по сценариям и общий каталог; сохраняет вместе с <see cref="AppSettings"/>.
/// </summary>
public sealed class RecentPathService
{
private readonly AppSettingsService _settingsService;
private string? _lastSeriesRenamerFolder;
private string? _lastConversionFilesFolder;
private string? _lastConversionFolder;
private string? _lastMergeFolder;
private string? _lastVideoInfoFolder;
private string? _lastTempFolder;
private string? _lastOutputFolder;
private string? _lastTrackExtractDestinationFolder;
private string? _lastCommonFolder;
public RecentPathService(AppSettingsService settingsService)
{
_settingsService = settingsService;
}
public void HydrateFrom(AppSettings settings)
{
_lastSeriesRenamerFolder = settings.LastSeriesRenamerFolder;
_lastConversionFilesFolder = settings.LastConversionFilesFolder;
_lastConversionFolder = settings.LastConversionFolder;
_lastMergeFolder = settings.LastMergeFolder;
_lastVideoInfoFolder = settings.LastVideoInfoFolder;
_lastTempFolder = settings.LastTempFolder;
_lastOutputFolder = settings.LastOutputFolder;
_lastTrackExtractDestinationFolder = settings.LastTrackExtractDestinationFolder;
_lastCommonFolder = settings.LastCommonFolder;
}
public void ApplyTo(AppSettings target)
{
target.LastSeriesRenamerFolder = _lastSeriesRenamerFolder;
target.LastConversionFilesFolder = _lastConversionFilesFolder;
target.LastConversionFolder = _lastConversionFolder;
target.LastMergeFolder = _lastMergeFolder;
target.LastVideoInfoFolder = _lastVideoInfoFolder;
target.LastTempFolder = _lastTempFolder;
target.LastOutputFolder = _lastOutputFolder;
target.LastTrackExtractDestinationFolder = _lastTrackExtractDestinationFolder;
target.LastCommonFolder = _lastCommonFolder;
}
/// <param name="extraFolderFallbackBeforeDefault">
/// Дополнительный существующий каталог перед стандартным «Видео» (для TEMP: текущий выбранный каталог из настроек).
/// </param>
public string GetInitialDirectory(RecentPathScenario scenario, string? extraFolderFallbackBeforeDefault = null)
{
foreach (var candidate in GetCandidateChain(scenario, extraFolderFallbackBeforeDefault))
{
if (IsUsableExistingDirectory(candidate))
{
return candidate!;
}
}
return Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
}
public void RememberChosenFiles(RecentPathScenario scenario, IReadOnlyList<string> chosenFilePaths)
{
if (chosenFilePaths.Count == 0)
{
return;
}
var dir = NormalizeExistingDirectory(Path.GetDirectoryName(chosenFilePaths[0]));
if (dir is null)
{
return;
}
SetScenarioFolder(scenario, dir);
_lastCommonFolder = dir;
Persist();
}
public string? GetNormalizedRememberedFolderPath(RecentPathScenario scenario)
{
var folder = GetScenarioStored(scenario);
if (string.IsNullOrWhiteSpace(folder))
{
return null;
}
try
{
return Path.GetFullPath(folder.Trim());
}
catch
{
return null;
}
}
public void RememberChosenFolder(RecentPathScenario scenario, string folderPathOrFile)
{
var dir = NormalizeExistingDirectory(folderPathOrFile);
if (dir is null)
{
return;
}
SetScenarioFolder(scenario, dir);
_lastCommonFolder = dir;
Persist();
}
private IEnumerable<string?> GetCandidateChain(RecentPathScenario scenario, string? extraFolderFallbackBeforeDefault)
{
yield return GetScenarioStored(scenario);
yield return _lastCommonFolder;
if (!string.IsNullOrWhiteSpace(extraFolderFallbackBeforeDefault))
{
yield return NormalizePathOnly(extraFolderFallbackBeforeDefault);
}
}
private string? GetScenarioStored(RecentPathScenario scenario) =>
scenario switch
{
RecentPathScenario.SeriesRenamer => _lastSeriesRenamerFolder,
RecentPathScenario.ConversionAddFiles => _lastConversionFilesFolder,
RecentPathScenario.ConversionAddFolder => _lastConversionFolder,
RecentPathScenario.Merge => _lastMergeFolder,
RecentPathScenario.VideoInfoOpenFile => _lastVideoInfoFolder,
RecentPathScenario.SettingsTempFolder => _lastTempFolder,
RecentPathScenario.SettingsOutputFolder => _lastOutputFolder,
RecentPathScenario.TrackExtractDestination => _lastTrackExtractDestinationFolder,
_ => null
};
private void SetScenarioFolder(RecentPathScenario scenario, string normalizedDirectory)
{
switch (scenario)
{
case RecentPathScenario.SeriesRenamer:
_lastSeriesRenamerFolder = normalizedDirectory;
break;
case RecentPathScenario.ConversionAddFiles:
_lastConversionFilesFolder = normalizedDirectory;
break;
case RecentPathScenario.ConversionAddFolder:
_lastConversionFolder = normalizedDirectory;
break;
case RecentPathScenario.Merge:
_lastMergeFolder = normalizedDirectory;
break;
case RecentPathScenario.VideoInfoOpenFile:
_lastVideoInfoFolder = normalizedDirectory;
break;
case RecentPathScenario.SettingsTempFolder:
_lastTempFolder = normalizedDirectory;
break;
case RecentPathScenario.SettingsOutputFolder:
_lastOutputFolder = normalizedDirectory;
break;
case RecentPathScenario.TrackExtractDestination:
_lastTrackExtractDestinationFolder = normalizedDirectory;
break;
default:
throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null);
}
}
private void Persist()
{
var disk = _settingsService.Load();
ApplyTo(disk);
_settingsService.Save(disk);
}
private static string? NormalizeExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var full = Path.GetFullPath(path.Trim());
return Directory.Exists(full) ? full : null;
}
catch
{
return null;
}
}
private static string? NormalizePathOnly(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
return Path.GetFullPath(path.Trim());
}
catch
{
return null;
}
}
private static bool IsUsableExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
try
{
return Directory.Exists(Path.GetFullPath(path.Trim()));
}
catch
{
return false;
}
}
}

View File

@ -0,0 +1,129 @@
using System.IO;
namespace EmbyToolbox.Services;
public sealed class SafeFileReplaceService
{
public async Task ReplaceAsync(
string originalPath,
string finalPath,
string tempOutputPath,
IProgress<int>? progress,
CancellationToken cancellationToken)
{
if (!File.Exists(tempOutputPath))
{
throw new IOException("Временный файл результата не найден.");
}
var tempInfo = new FileInfo(tempOutputPath);
if (tempInfo.Length <= 0)
{
throw new IOException("Временный файл результата пуст.");
}
var backupPath = originalPath + ".bak_replace_tmp";
var finalBackupPath = finalPath + ".bak_existing_tmp";
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
if (File.Exists(finalBackupPath))
{
File.Delete(finalBackupPath);
}
File.Move(originalPath, backupPath, overwrite: false);
try
{
if (!string.Equals(originalPath, finalPath, StringComparison.OrdinalIgnoreCase) && File.Exists(finalPath))
{
File.Move(finalPath, finalBackupPath, overwrite: false);
}
var sameRoot = string.Equals(Path.GetPathRoot(finalPath), Path.GetPathRoot(tempOutputPath), StringComparison.OrdinalIgnoreCase);
if (sameRoot)
{
File.Move(tempOutputPath, finalPath, overwrite: true);
progress?.Report(99);
}
else
{
await CopyWithProgressAsync(tempOutputPath, finalPath, progress, cancellationToken).ConfigureAwait(false);
File.Delete(tempOutputPath);
}
var outInfo = new FileInfo(finalPath);
if (!outInfo.Exists || outInfo.Length <= 0)
{
throw new IOException("Новый файл после замены невалиден.");
}
File.Delete(backupPath);
if (File.Exists(finalBackupPath))
{
File.Delete(finalBackupPath);
}
}
catch
{
try
{
if (File.Exists(finalPath))
{
var i = new FileInfo(finalPath);
if (i.Length == 0)
{
File.Delete(finalPath);
}
}
}
catch
{
// ignore
}
if (File.Exists(backupPath))
{
File.Move(backupPath, originalPath, overwrite: true);
}
if (File.Exists(finalBackupPath))
{
File.Move(finalBackupPath, finalPath, overwrite: true);
}
throw;
}
}
private static async Task CopyWithProgressAsync(string src, string dst, IProgress<int>? progress, CancellationToken token)
{
const int bufferSize = 1024 * 1024;
await using var input = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
await using var output = new FileStream(dst, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, useAsync: true);
var total = input.Length;
var copied = 0L;
var buffer = new byte[bufferSize];
while (true)
{
var read = await input.ReadAsync(buffer, token).ConfigureAwait(false);
if (read <= 0)
{
break;
}
await output.WriteAsync(buffer.AsMemory(0, read), token).ConfigureAwait(false);
copied += read;
if (total > 0)
{
var frac = copied / (double)total;
var extra = frac >= 1.0 ? 9 : (int)Math.Floor(frac * 10.0);
extra = Math.Clamp(extra, 0, 9);
progress?.Report(90 + extra);
}
}
}
}

View File

@ -0,0 +1,468 @@
using System.Text.RegularExpressions;
using System.IO;
namespace EmbyToolbox.Services;
public sealed class SeriesRenamerService
{
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mkv",".mp4",".avi",".mov",".wmv",".flv",".ts",".m2ts",".webm",".mpeg",".mpg",".m4v",".3gp",".ogv",".vob",".rmvb",".asf",".divx",".f4v",".mts",".m2v",".mp2",".mpv",".qt",".hevc",".h265",".h264"
};
private static readonly HashSet<string> SidecarExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".srt",".ass",".ssa",".sub",".idx",".sup",".vtt",".txt",".smi",".rt",
".aac",".ac3",".eac3",".dts",".flac",".m4a",".mp3",".opus",".wav",".oga",
".jpg",".jpeg",".png",".webp",".nfo",".xml",".cue",".log",".url"
};
public SeriesRenamePreview BuildPreview(string? rootPath, string? newSeriesName)
{
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
{
return SeriesRenamePreview.Unsupported("Папка сериала не выбрана.");
}
var seriesName = (newSeriesName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(seriesName))
{
return SeriesRenamePreview.Unsupported("Пустое имя сериала.");
}
if (seriesName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
return SeriesRenamePreview.Unsupported("Имя сериала содержит недопустимые символы.");
}
var rootInfo = new DirectoryInfo(rootPath);
var seasonDirs = rootInfo.GetDirectories().OrderBy(d => d.Name, NaturalStringComparer.Instance).ToList();
if (seasonDirs.Count == 0)
{
return SeriesRenamePreview.Unsupported("Нет папок сезонов.");
}
foreach (var season in seasonDirs)
{
if (season.GetDirectories().Length > 0)
{
return SeriesRenamePreview.Unsupported($"В сезоне '{season.Name}' обнаружены вложенные папки.");
}
}
var seasonNumbers = ResolveSeasonNumbers(seasonDirs);
var operations = new List<SeriesRenameOperation>();
var rootTargetPath = Path.Combine(rootInfo.Parent?.FullName ?? rootInfo.FullName, seriesName);
var currentRoot = new SeriesNode("root", rootInfo.Name, "Folder");
var previewRoot = new SeriesNode("root", seriesName, "Folder");
if (!PathsEqual(rootInfo.FullName, rootTargetPath))
{
operations.Add(new SeriesRenameOperation(OperationKind.RootDirectory, rootInfo.FullName, rootTargetPath));
}
for (var i = 0; i < seasonDirs.Count; i++)
{
var season = seasonDirs[i];
var seasonNo = seasonNumbers[i];
var seasonTargetName = $"Season {seasonNo:00}";
var seasonTargetPath = Path.Combine(rootInfo.FullName, seasonTargetName);
var seasonKey = $"season:{i}:{season.Name}";
var currentSeasonNode = new SeriesNode(seasonKey, season.Name, "Folder");
var previewSeasonNode = new SeriesNode(seasonKey, seasonTargetName, "Folder");
currentRoot.Children.Add(currentSeasonNode);
previewRoot.Children.Add(previewSeasonNode);
if (!PathsEqual(season.FullName, seasonTargetPath))
{
operations.Add(new SeriesRenameOperation(OperationKind.SeasonDirectory, season.FullName, seasonTargetPath));
}
var allFiles = season.GetFiles().OrderBy(f => f.Name, NaturalStringComparer.Instance).ToList();
var videoFiles = allFiles.Where(IsVideoFile).OrderBy(f => f.Name, NaturalStringComparer.Instance).ToList();
var plannedSidecars = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var e = 0; e < videoFiles.Count; e++)
{
var video = videoFiles[e];
var newStem = $"{seriesName} S{seasonNo:00}E{e + 1:00}";
var newVideoName = $"{newStem}{video.Extension}";
var newVideoPath = Path.Combine(video.DirectoryName!, newVideoName);
var videoKey = $"{seasonKey}/video:{e}:{video.Name}";
currentSeasonNode.Children.Add(new SeriesNode(videoKey, video.Name, "Video"));
previewSeasonNode.Children.Add(new SeriesNode(videoKey, newVideoName, "Video"));
if (!PathsEqual(video.FullName, newVideoPath))
{
operations.Add(new SeriesRenameOperation(OperationKind.File, video.FullName, newVideoPath));
}
var stem = Path.GetFileNameWithoutExtension(video.Name);
var linked = allFiles
.Where(f => !PathsEqual(f.FullName, video.FullName))
.Where(f => f.Name.StartsWith(stem + ".", StringComparison.OrdinalIgnoreCase))
.Where(IsSidecarFile)
.ToList();
foreach (var sidecar in linked)
{
if (!plannedSidecars.Add(sidecar.FullName))
{
continue;
}
var suffix = sidecar.Name[stem.Length..];
var newSidecarName = $"{newStem}{suffix}";
var newSidecarPath = Path.Combine(sidecar.DirectoryName!, newSidecarName);
var sidecarKey = $"{seasonKey}/sidecar:{sidecar.Name}";
currentSeasonNode.Children.Add(new SeriesNode(sidecarKey, sidecar.Name, "Sidecar"));
previewSeasonNode.Children.Add(new SeriesNode(sidecarKey, newSidecarName, "Sidecar"));
if (!PathsEqual(sidecar.FullName, newSidecarPath))
{
operations.Add(new SeriesRenameOperation(OperationKind.File, sidecar.FullName, newSidecarPath));
}
}
}
}
var deduped = operations
.GroupBy(o => o.SourcePath, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Last())
.ToList();
return SeriesRenamePreview.Supported(currentRoot, previewRoot, deduped);
}
public SeriesRenameExecutionResult ExecutePreview(SeriesRenamePreview preview, string currentRootPath)
{
if (!preview.IsSupported)
{
return SeriesRenameExecutionResult.Fail(preview.UnsupportedReason ?? "Предпросмотр недоступен.", currentRootPath, currentRootPath, false);
}
var fileOps = preview.Operations.Where(o => o.Kind == OperationKind.File && !PathsEqual(o.SourcePath, o.TargetPath)).ToList();
var seasonOps = preview.Operations.Where(o => o.Kind == OperationKind.SeasonDirectory && !PathsEqual(o.SourcePath, o.TargetPath)).ToList();
var rootOp = preview.Operations.FirstOrDefault(o => o.Kind == OperationKind.RootDirectory && !PathsEqual(o.SourcePath, o.TargetPath));
var oldRootPath = currentRootPath;
var newRootPath = currentRootPath;
var rootWasRenamed = false;
var rollbackActions = new Stack<(string from, string to, bool isDir)>();
try
{
ExecuteFileOps(fileOps, rollbackActions);
ExecuteSeasonOps(seasonOps, rollbackActions);
if (rootOp is not null)
{
newRootPath = ExecuteRootOp(rootOp, rollbackActions);
rootWasRenamed = !PathsEqual(oldRootPath, newRootPath);
}
return SeriesRenameExecutionResult.Ok(oldRootPath, newRootPath, rootWasRenamed);
}
catch (Exception ex)
{
while (rollbackActions.Count > 0)
{
var action = rollbackActions.Pop();
try
{
MovePath(action.from, action.to, action.isDir);
}
catch
{
// Best effort rollback.
}
}
return SeriesRenameExecutionResult.Fail(ex.Message, oldRootPath, newRootPath, rootWasRenamed);
}
}
private static void ExecuteFileOps(List<SeriesRenameOperation> fileOps, Stack<(string from, string to, bool isDir)> rollbackActions)
{
if (fileOps.Count == 0)
{
return;
}
var resolvedTargets = ResolveTargets(fileOps, isDirectory: false);
var staged = new List<(string source, string temp, string final)>();
var finalized = new List<(string source, string temp, string final)>();
try
{
foreach (var op in fileOps)
{
var temp = GetTempPath(op.SourcePath);
MovePath(op.SourcePath, temp, isDirectory: false);
staged.Add((op.SourcePath, temp, resolvedTargets[op.SourcePath]));
}
foreach (var item in staged)
{
MovePath(item.temp, item.final, isDirectory: false);
finalized.Add(item);
}
}
catch
{
for (var i = finalized.Count - 1; i >= 0; i--)
{
var item = finalized[i];
if (File.Exists(item.final))
{
MovePath(item.final, item.temp, isDirectory: false);
}
}
for (var i = staged.Count - 1; i >= 0; i--)
{
var item = staged[i];
if (File.Exists(item.temp))
{
MovePath(item.temp, item.source, isDirectory: false);
}
}
throw;
}
foreach (var item in finalized)
{
rollbackActions.Push((item.final, item.source, false));
}
}
private static void ExecuteSeasonOps(List<SeriesRenameOperation> seasonOps, Stack<(string from, string to, bool isDir)> rollbackActions)
{
if (seasonOps.Count == 0)
{
return;
}
var resolved = ResolveTargets(seasonOps, isDirectory: true);
var staged = new List<(string source, string temp, string final)>();
var finalized = new List<(string source, string temp, string final)>();
try
{
foreach (var op in seasonOps.OrderByDescending(o => o.SourcePath.Length))
{
var temp = GetTempPath(op.SourcePath);
MovePath(op.SourcePath, temp, isDirectory: true);
staged.Add((op.SourcePath, temp, resolved[op.SourcePath]));
}
foreach (var item in staged)
{
MovePath(item.temp, item.final, isDirectory: true);
finalized.Add(item);
}
}
catch
{
for (var i = finalized.Count - 1; i >= 0; i--)
{
var item = finalized[i];
if (Directory.Exists(item.final))
{
MovePath(item.final, item.temp, isDirectory: true);
}
}
for (var i = staged.Count - 1; i >= 0; i--)
{
var item = staged[i];
if (Directory.Exists(item.temp))
{
MovePath(item.temp, item.source, isDirectory: true);
}
}
throw;
}
foreach (var item in finalized)
{
rollbackActions.Push((item.final, item.source, true));
}
}
private static string ExecuteRootOp(SeriesRenameOperation rootOp, Stack<(string from, string to, bool isDir)> rollbackActions)
{
var resolved = ResolveTargets(new List<SeriesRenameOperation> { rootOp }, isDirectory: true);
var final = resolved[rootOp.SourcePath];
MovePath(rootOp.SourcePath, final, isDirectory: true);
rollbackActions.Push((final, rootOp.SourcePath, true));
return final;
}
private static Dictionary<string, string> ResolveTargets(List<SeriesRenameOperation> ops, bool isDirectory)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var sourceSet = ops.Select(o => o.SourcePath).ToHashSet(StringComparer.OrdinalIgnoreCase);
var reserved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var op in ops)
{
var candidate = op.TargetPath;
var duplicate = 1;
while (reserved.Contains(candidate) || (PathExists(candidate, isDirectory) && !sourceSet.Contains(candidate)))
{
candidate = AppendDuplicate(op.TargetPath, duplicate++, isDirectory);
}
reserved.Add(candidate);
result[op.SourcePath] = candidate;
}
return result;
}
private static bool PathExists(string path, bool isDirectory)
{
return isDirectory ? Directory.Exists(path) : File.Exists(path);
}
private static string AppendDuplicate(string path, int duplicate, bool isDirectory)
{
var dir = Path.GetDirectoryName(path) ?? string.Empty;
var name = Path.GetFileNameWithoutExtension(path);
var ext = isDirectory ? string.Empty : Path.GetExtension(path);
return Path.Combine(dir, $"{name}_duplicate_{duplicate}{ext}");
}
private static void MovePath(string from, string to, bool isDirectory)
{
var parent = Path.GetDirectoryName(to);
if (!string.IsNullOrWhiteSpace(parent))
{
Directory.CreateDirectory(parent);
}
if (isDirectory)
{
Directory.Move(from, to);
}
else
{
File.Move(from, to);
}
}
private static string GetTempPath(string sourcePath)
{
var dir = Path.GetDirectoryName(sourcePath) ?? string.Empty;
var name = Path.GetFileName(sourcePath);
return Path.Combine(dir, $"{name}.__emby_tmp_{Guid.NewGuid():N}");
}
private static bool IsVideoFile(FileInfo file)
{
return VideoExtensions.Contains(file.Extension);
}
private static bool IsSidecarFile(FileInfo file)
{
return SidecarExtensions.Contains(file.Extension);
}
private static List<int> ResolveSeasonNumbers(List<DirectoryInfo> seasonDirs)
{
var numeric = seasonDirs
.Select(d => TryParseSeasonNumber(d.Name))
.ToList();
if (numeric.All(v => v is not null))
{
return numeric.Select(v => v!.Value).ToList();
}
return Enumerable.Range(1, seasonDirs.Count).ToList();
}
private static int? TryParseSeasonNumber(string name)
{
var trimmed = name.Trim();
if (Regex.IsMatch(trimmed, @"^\d+$") && int.TryParse(trimmed, out var n))
{
return n;
}
return null;
}
private static bool PathsEqual(string a, string b)
{
return string.Equals(
Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar),
Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase);
}
}
public sealed class SeriesRenamePreview
{
private SeriesRenamePreview(bool isSupported, string? unsupportedReason, SeriesNode? currentTree, SeriesNode? previewTree, IReadOnlyList<SeriesRenameOperation> operations)
{
IsSupported = isSupported;
UnsupportedReason = unsupportedReason;
CurrentTree = currentTree;
PreviewTree = previewTree;
Operations = operations;
}
public bool IsSupported { get; }
public string? UnsupportedReason { get; }
public SeriesNode? CurrentTree { get; }
public SeriesNode? PreviewTree { get; }
public IReadOnlyList<SeriesRenameOperation> Operations { get; }
public static SeriesRenamePreview Unsupported(string reason) => new(false, reason, null, null, Array.Empty<SeriesRenameOperation>());
public static SeriesRenamePreview Supported(SeriesNode current, SeriesNode preview, IReadOnlyList<SeriesRenameOperation> operations) => new(true, null, current, preview, operations);
}
public sealed record SeriesNode(string NodeKey, string Name, string Kind)
{
public List<SeriesNode> Children { get; } = new();
}
public sealed record SeriesRenameOperation(OperationKind Kind, string SourcePath, string TargetPath);
public enum OperationKind
{
File,
SeasonDirectory,
RootDirectory
}
public sealed class SeriesRenameExecutionResult
{
private SeriesRenameExecutionResult(bool isSuccess, string error, string oldRootPath, string newRootPath, bool rootWasRenamed)
{
IsSuccess = isSuccess;
Error = error;
OldRootPath = oldRootPath;
NewRootPath = newRootPath;
RootWasRenamed = rootWasRenamed;
}
public bool IsSuccess { get; }
public string Error { get; }
public string OldRootPath { get; }
public string NewRootPath { get; }
public bool RootWasRenamed { get; }
public static SeriesRenameExecutionResult Ok(string oldRootPath, string newRootPath, bool rootWasRenamed) =>
new(true, string.Empty, oldRootPath, newRootPath, rootWasRenamed);
public static SeriesRenameExecutionResult Fail(string error, string oldRootPath, string newRootPath, bool rootWasRenamed) =>
new(false, error, oldRootPath, newRootPath, rootWasRenamed);
}

View File

@ -0,0 +1,221 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Поиск внешних аудио и субтитров рядом с видеофайлом; для контейнеров с несколькими аудиопотоками — ffprobe.</summary>
public sealed class SidecarDiscoveryService
{
private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase;
private static readonly HashSet<string> AudioExts = new(IC)
{
".mka", ".mkv", ".mp4", ".m4a",
".ac3", ".eac3", ".aac", ".dts", ".flac", ".wav", ".opus", ".ogg", ".mp3", ".wma", ".aiff", ".aif", ".m4b", ".m4r"
};
/// <summary>Расширения: внутри файла может быть несколько аудиопотоков → полный ffprobe.</summary>
private static readonly HashSet<string> MultiStreamAudioProbeExts = new(IC)
{
".mka", ".mkv", ".mp4", ".m4a"
};
private static readonly HashSet<string> SubExts = new(IC)
{
".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup", ".smi"
};
private static readonly HashSet<string> FontExts = new(IC)
{
".ttf", ".otf", ".ttc", ".otc"
};
private readonly LoggingService? _logging;
public SidecarDiscoveryService(LoggingService? logging = null) => _logging = logging;
public async Task<SidecarDiscoveryResult> DiscoverAsync(
string videoPath,
FfprobeService ffprobe,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
{
return new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
}
var dir = Path.GetDirectoryName(videoPath);
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
{
return new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
}
var baseName = Path.GetFileNameWithoutExtension(videoPath);
var full = Path.GetFullPath(videoPath);
var list = new List<SidecarFile>();
foreach (var path in Directory.EnumerateFiles(dir))
{
if (string.Equals(path, full, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var nameNoExt = Path.GetFileNameWithoutExtension(path);
if (!string.Equals(nameNoExt, baseName, StringComparison.OrdinalIgnoreCase)
&& !nameNoExt.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var ext = Path.GetExtension(path);
if (AudioExts.Contains(ext))
{
list.Add(new SidecarFile(path, isAudio: true, isSubtitle: false));
}
else if (SubExts.Contains(ext))
{
list.Add(new SidecarFile(path, isAudio: false, isSubtitle: true));
}
}
foreach (var subDir in Directory.EnumerateDirectories(dir))
{
var name = Path.GetFileName(subDir);
if (!name.Equals("font", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("fonts", StringComparison.OrdinalIgnoreCase))
{
continue;
}
IEnumerable<string> fontFiles;
try
{
fontFiles = Directory.EnumerateFiles(subDir, "*.*", SearchOption.AllDirectories);
}
catch
{
continue;
}
foreach (var f in fontFiles)
{
if (!FontExts.Contains(Path.GetExtension(f)))
{
continue;
}
list.Add(new SidecarFile(f, isAudio: false, isSubtitle: false, isFont: true));
}
}
var sorted = list.OrderBy(s => s.FileName, IC).ToList();
var externalAudioByPath = new Dictionary<string, ExternalAudioFile>(IC);
var audioPaths = sorted.Where(s => s.IsAudio).Select(s => s.FullPath).Distinct(IC).OrderBy(Path.GetFileName, IC).ToList();
foreach (var audioPath in audioPaths)
{
var streams = MultiStreamAudioProbeExts.Contains(Path.GetExtension(audioPath))
? await ProbeExternalAudioStreamsAsync(ffprobe, audioPath, cancellationToken).ConfigureAwait(false)
: CreateSingleFallbackStream(audioPath);
externalAudioByPath[audioPath] = new ExternalAudioFile(audioPath, streams);
}
var externalsOrdered = audioPaths.Select(p => externalAudioByPath[p]).ToList();
return new SidecarDiscoveryResult(sorted, externalsOrdered);
}
private async Task<IReadOnlyList<ExternalAudioStream>> ProbeExternalAudioStreamsAsync(
FfprobeService ffprobe,
string audioPath,
CancellationToken cancellationToken)
{
try
{
var result = await ffprobe.AnalyzeAsync(audioPath, cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || string.IsNullOrWhiteSpace(result.Json))
{
_logging?.Warning($"ffprobe sidecar аудио: {result.Error ?? "нет данных"} — {audioPath}", "conversion.sidecar");
return CreateSingleFallbackStream(audioPath);
}
var media = MediaAnalysisParser.TryParse(result.Json);
var audios = media?.AudioStreams?.OrderBy(a => a.Index).ToList();
if (audios is not { Count: > 0 })
{
return CreateSingleFallbackStream(audioPath);
}
var ordinal = 0;
var list = new List<ExternalAudioStream>(audios.Count);
foreach (var a in audios)
{
list.Add(
new ExternalAudioStream
{
FileFullPath = audioPath,
StreamOrdinal = ordinal++,
CodecName = string.IsNullOrWhiteSpace(a.CodecName) ? "?" : a.CodecName,
TitleFromProbe = string.IsNullOrWhiteSpace(a.Title) ? null : a.Title.Trim(),
Channels = a.Channels,
SampleRateHz = a.SampleRateHz,
BitRateBps = a.BitRateBps
});
}
return list;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logging?.Warning($"ffprobe sidecar аудио: {ex.Message} — {audioPath}", "conversion.sidecar");
return CreateSingleFallbackStream(audioPath);
}
}
private static ExternalAudioStream[] CreateSingleFallbackStream(string audioPath) =>
[
new ExternalAudioStream
{
FileFullPath = audioPath,
StreamOrdinal = 0,
CodecName = GuessCodecFromExtension(Path.GetExtension(audioPath)),
TitleFromProbe = null,
Channels = null,
SampleRateHz = null,
BitRateBps = null
}
];
internal static string GuessCodecFromExtension(string? ext)
{
if (string.IsNullOrWhiteSpace(ext))
{
return "?";
}
ext = ext.Trim().ToLowerInvariant();
return ext switch
{
".ac3" => "ac3",
".eac3" => "eac3",
".dts" => "dts",
".aac" => "aac",
".mp3" => "mp3",
".opus" => "opus",
".ogg" => "vorbis",
".flac" => "flac",
".wav" => "pcm_s16le",
".wma" => "wmav2",
".mka" or ".mkv" or ".mp4" or ".m4a" => "unknown",
_ => ext.TrimStart('.')
};
}
}

View File

@ -0,0 +1,128 @@
using System.IO;
using System.Text.RegularExpressions;
namespace EmbyToolbox.Services;
/// <summary>Распознаёт человекочитаемый title внешних audio/subtitle sidecar-файлов.</summary>
public sealed class SidecarTitleResolver
{
private static readonly char[] StartDelimiters = ['.', '_', '-', ' '];
private static readonly string[] TechnicalTailTokens = ["rus", "eng", "audio", "sub", "subtitle", "subtitles"];
public string ResolveExternalAudioTitle(
string videoPath,
string sidecarPath,
int streamIndex,
string? streamTags)
{
var tagTitle = NormalizeTitleOrNull(streamTags);
if (tagTitle is not null)
{
return tagTitle;
}
var byName = TryRecognizeSidecarTitle(videoPath, sidecarPath);
if (byName is not null)
{
return byName;
}
return BuildRusAudioFallback(streamIndex);
}
public string ResolveExternalSubtitleTitle(string videoPath, string sidecarPath, int index)
{
var byName = TryRecognizeSidecarTitle(videoPath, sidecarPath);
if (byName is not null)
{
return byName;
}
return BuildRusSubtitleFallback(index);
}
public string? TryRecognizeSidecarTitle(string videoPath, string sidecarPath)
{
if (string.IsNullOrWhiteSpace(videoPath) || string.IsNullOrWhiteSpace(sidecarPath))
{
return null;
}
var videoBase = Path.GetFileNameWithoutExtension(videoPath);
var sidecarBase = Path.GetFileNameWithoutExtension(sidecarPath);
if (string.IsNullOrWhiteSpace(videoBase) || string.IsNullOrWhiteSpace(sidecarBase))
{
return null;
}
if (!sidecarBase.StartsWith(videoBase, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (sidecarBase.Length <= videoBase.Length)
{
return null;
}
var delimiter = sidecarBase[videoBase.Length];
if (Array.IndexOf(StartDelimiters, delimiter) < 0)
{
return null;
}
var rawSuffix = sidecarBase[(videoBase.Length + 1)..];
return CleanupCandidate(rawSuffix);
}
private static string? CleanupCandidate(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
var cleaned = raw.Trim().Trim('.', '_', '-', ' ');
if (string.IsNullOrWhiteSpace(cleaned))
{
return null;
}
cleaned = cleaned.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ');
cleaned = Regex.Replace(cleaned, "\\s+", " ").Trim();
if (string.IsNullOrWhiteSpace(cleaned))
{
return null;
}
var tokens = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
while (tokens.Count > 0 && TechnicalTailTokens.Contains(tokens[^1], StringComparer.OrdinalIgnoreCase))
{
tokens.RemoveAt(tokens.Count - 1);
}
if (tokens.Count == 0)
{
return null;
}
cleaned = string.Join(' ', tokens).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
public static string BuildRusAudioFallback(int index) => index <= 1 ? "RUS" : $"RUS {index}";
public static string BuildRusSubtitleFallback(int index) => index <= 0 ? "RUS" : $"RUS {index}";
public static string? NormalizeTitleOrNull(string? title)
{
if (string.IsNullOrWhiteSpace(title))
{
return null;
}
var normalized = title.Trim();
return normalized.Equals("unknown", StringComparison.OrdinalIgnoreCase) ? null : normalized;
}
}

View File

@ -0,0 +1,108 @@
using System.IO;
namespace EmbyToolbox.Services;
/// <summary>Область применения snapshot: каталог эпизода и опционально общий корень батча.</summary>
public static class SnapshotScopePaths
{
private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase;
/// <summary>Абсолютный путь без завершающего разделителя.</summary>
public static string NormalizeScopeDirectory(string directoryPath)
{
if (string.IsNullOrWhiteSpace(directoryPath))
{
return string.Empty;
}
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directoryPath.Trim()));
}
public static string NormalizeScopeBatchRootNullable(string? path) =>
string.IsNullOrWhiteSpace(path) ? string.Empty : NormalizeScopeDirectory(path);
public static string GetVideoScopeDirectory(string videoFileFullPath)
{
var dir = Path.GetDirectoryName(Path.GetFullPath(videoFileFullPath));
return dir is null ? string.Empty : NormalizeScopeDirectory(dir);
}
/// <summary>Узкий общий предок каталогов всех указанных видеофайлов (полные пути).</summary>
public static string? TryGetLowestCommonAncestorDirectory(IReadOnlyList<string> videoFileAbsolutePaths)
{
if (videoFileAbsolutePaths.Count == 0)
{
return null;
}
var dirs = videoFileAbsolutePaths
.Select(static p =>
{
var d = Path.GetDirectoryName(Path.GetFullPath(p));
return d is null ? string.Empty : NormalizeScopeDirectory(d);
})
.Where(static d => d.Length > 0)
.Distinct(IC)
.ToList();
if (dirs.Count == 0)
{
return null;
}
var acc = dirs[0];
for (var i = 1; i < dirs.Count; i++)
{
acc = LowestCommonAncestorOfTwoDirectories(acc, dirs[i]);
if (string.IsNullOrEmpty(acc))
{
return null;
}
}
return acc;
}
private static string LowestCommonAncestorOfTwoDirectories(string dirA, string dirB)
{
if (dirA.Length == 0 || dirB.Length == 0)
{
return string.Empty;
}
var ancestorsA = new HashSet<string>(EnumerateAncestorDirectoriesInclusive(dirA), IC);
foreach (var cand in EnumerateAncestorDirectoriesInclusive(dirB))
{
if (ancestorsA.Contains(cand))
{
return cand;
}
}
return string.Empty;
}
/// <summary>От указанной папки вверх к корню включая сам каталог.</summary>
private static IEnumerable<string> EnumerateAncestorDirectoriesInclusive(string normalizedAbsoluteDir)
{
var d = NormalizeScopeDirectory(normalizedAbsoluteDir);
while (!string.IsNullOrEmpty(d))
{
yield return d;
var parent = Path.GetDirectoryName(d);
if (string.IsNullOrEmpty(parent))
{
yield break;
}
parent = NormalizeScopeDirectory(parent);
if (string.Equals(parent, d, StringComparison.OrdinalIgnoreCase))
{
yield break;
}
d = parent;
}
}
}

View File

@ -0,0 +1,138 @@
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Правила совместимости субтитров с контейнером MKV/MP4 и codec copy в FFmpeg.</summary>
public static class SubtitleCodecRules
{
public static string Normalize(string? codec) =>
string.IsNullOrWhiteSpace(codec) ? string.Empty : codec.Trim().ToLowerInvariant();
public static bool IsTeletext(string? codec) => Normalize(codec) == "dvb_teletext";
public static bool IsUnknownSubtitleCodec(string? codec)
{
var n = Normalize(codec);
return n is "" or "?" or "unknown";
}
/// <summary>Кодеки, которые можно mux copy в Matroska (без teletext / mov_text и пр.).</summary>
public static bool IsMkvCopySafe(string? codec)
{
return Normalize(codec) switch
{
"subrip" or "srt" or "ass" or "ssa" => true,
"webvtt" or "wvtt" => true,
"hdmv_pgs_subtitle" or "pgssub" => true,
"dvd_subtitle" or "dvdsub" => true,
_ => false
};
}
public static bool IsMp4TextDirectCopy(string? codec) => Normalize(codec) == "mov_text";
/// <summary>Текстовые дорожки, для MP4 требующие перекодирования в mov_text (не mux copy).</summary>
public static bool Mp4RequiresSubtitleTranscode(string? codec)
{
return Normalize(codec) switch
{
"subrip" or "srt" or "ass" or "ssa" => true,
_ => false
};
}
public static bool TargetsMkv(string? container) =>
!string.IsNullOrWhiteSpace(container) &&
(container.Contains("mkv", StringComparison.OrdinalIgnoreCase) ||
container.Contains("matro", StringComparison.OrdinalIgnoreCase));
public static bool TargetsMp4(string? container) =>
!string.IsNullOrWhiteSpace(container) &&
(container.Contains("mp4", StringComparison.OrdinalIgnoreCase) ||
container.Contains("m4v", StringComparison.OrdinalIgnoreCase) ||
container.Contains("mov", StringComparison.OrdinalIgnoreCase));
/// <summary>Действо по умолчанию для встроенной дорожки субтитров после анализа.</summary>
public static TrackActionKind DefaultEmbeddedSubtitleAction(string? codecName, string? profileContainer)
{
var c = codecName;
if (IsTeletext(c) || IsUnknownSubtitleCodec(c))
{
return TrackActionKind.Remove;
}
if (TargetsMkv(profileContainer))
{
return IsMkvCopySafe(c) ? TrackActionKind.Keep : TrackActionKind.Remove;
}
if (TargetsMp4(profileContainer))
{
if (IsMp4TextDirectCopy(c))
{
return TrackActionKind.Keep;
}
if (Mp4RequiresSubtitleTranscode(c))
{
return TrackActionKind.Convert;
}
return TrackActionKind.Remove;
}
return IsMkvCopySafe(c) ? TrackActionKind.Keep : TrackActionKind.Remove;
}
/// <summary>Встроенную субдорожку с этим решением включают в ffmpeg -map только если истина.</summary>
public static bool ShouldMapEmbeddedSubtitle(
MediaAnalysisResult media,
TrackOverrideEntry t,
string effectiveContainer)
{
if (t.Source != SourceKind.Embedded || t.StreamKind != MediaStreamKind.Subtitle)
{
return true;
}
if (t.Action == TrackActionKind.Remove)
{
return false;
}
var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
if (TargetsMkv(effectiveContainer))
{
if (!IsMkvCopySafe(codec))
{
return false;
}
return t.Action is TrackActionKind.Keep or TrackActionKind.Convert;
}
if (TargetsMp4(effectiveContainer))
{
if (IsMp4TextDirectCopy(codec) && t.Action == TrackActionKind.Keep)
{
return true;
}
if (Mp4RequiresSubtitleTranscode(codec) &&
t.Action is TrackActionKind.Convert or TrackActionKind.Keep)
{
return true;
}
return false;
}
return IsMkvCopySafe(codec) ? t.Action != TrackActionKind.Remove : false;
}
/// <summary>Строковая подпись Codec для дорожки (субтитры отображать как текстовые / teletext).</summary>
public static string EmbeddedSubtitleCodecLabel(string? ffprobeCodec) =>
IsTeletext(ffprobeCodec) ? "dvb_teletext (subtitle)" : ffprobeCodec ?? "?";
}

View File

@ -0,0 +1,33 @@
using System.IO;
using System.Linq;
namespace EmbyToolbox.Services;
/// <summary>Единый список поддерживаемых расширений видео для очереди, объединения и анализа.</summary>
public static class SupportedVideoFormats
{
private static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".ts", ".m2ts", ".webm", ".mpeg", ".mpg", ".m4v",
".3gp", ".ogv", ".vob", ".rmvb", ".asf", ".divx", ".f4v", ".mts", ".m2v", ".mp2", ".mpv", ".qt",
".hevc", ".h265", ".h264",
};
public static bool IsSupportedVideoFile(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var extension = Path.GetExtension(path);
return !string.IsNullOrWhiteSpace(extension) && Extensions.Contains(extension);
}
/// <summary>Фильтр для OpenFileDialog: только поддерживаемые видео.</summary>
public static string BuildOpenFileDialogFilter()
{
var globs = string.Join(";", Extensions.OrderBy(e => e, StringComparer.Ordinal).Select(e => $"*{e}"));
return $"Видеофайлы|{globs}|Все файлы|*.*";
}
}

View File

@ -0,0 +1,76 @@
using System.Globalization;
using System.IO;
namespace EmbyToolbox.Services;
/// <summary>Единый каталог <c>extract\audio|subtitles|attachments</c> и имена без молчаливого перезаписывания.</summary>
public static class TrackExtractOutputPaths
{
/// <returns>Абсолютный путь к каталогу <c>…\extract</c> или null.</returns>
public static string? TryPrepareExtractDirectories(string destinationRootFolder)
{
if (string.IsNullOrWhiteSpace(destinationRootFolder))
{
return null;
}
try
{
var root = Path.GetFullPath(destinationRootFolder.Trim());
var extractRoot = Path.Combine(root, "extract");
Directory.CreateDirectory(Path.Combine(extractRoot, "audio"));
Directory.CreateDirectory(Path.Combine(extractRoot, "subtitles"));
Directory.CreateDirectory(Path.Combine(extractRoot, "attachments"));
return extractRoot;
}
catch (ArgumentException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// <summary>Если в <paramref name="parentDirectory"/> уже есть файл с таким именем — добавляет <c>_1</c>, <c>_2</c>… перед расширением.</summary>
public static string AllocateUniqueFilename(string parentDirectory, string desiredFileNameOnly)
{
var safe = Path.GetFileName(desiredFileNameOnly);
if (string.IsNullOrEmpty(safe))
{
safe = "output.bin";
}
if (!Exists(parentDirectory, safe))
{
return safe;
}
var stem = Path.GetFileNameWithoutExtension(safe);
var ext = Path.GetExtension(safe);
if (string.IsNullOrEmpty(stem))
{
stem = "file";
}
for (var i = 1; i < 10_000; i++)
{
var candidate = $"{stem}_{i.ToString(CultureInfo.InvariantCulture)}{ext}";
if (!Exists(parentDirectory, candidate))
{
return candidate;
}
}
return $"{stem}_{Guid.NewGuid():N}{ext}";
}
private static bool Exists(string parentDirectory, string fileNameOnly) =>
File.Exists(Path.Combine(parentDirectory, fileNameOnly));
}

View File

@ -0,0 +1,55 @@
using System.IO;
namespace EmbyToolbox.Services;
/// <summary>Разрешённые контейнеры для извлечения дорожек.</summary>
public static class TrackExtractionFormats
{
private static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
".mkv", ".mp4",
};
public static bool IsSupportedPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var ext = Path.GetExtension(path);
return !string.IsNullOrWhiteSpace(ext) && Extensions.Contains(ext);
}
public static string BuildOpenFileFilter() => "MKV и MP4|*.mkv;*.mp4|Все файлы|*.*";
public static IReadOnlyList<string> EnumerateMediaFilesRecursive(string rootDirectory)
{
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
{
return Array.Empty<string>();
}
var bag = new List<string>();
try
{
foreach (var file in Directory.EnumerateFiles(rootDirectory, "*.*", SearchOption.AllDirectories))
{
if (IsSupportedPath(file))
{
bag.Add(Path.GetFullPath(file));
}
}
}
catch (UnauthorizedAccessException)
{
// игнорируем недоступные подкаталоги
}
catch (IOException)
{
}
bag.Sort(StringComparer.OrdinalIgnoreCase);
return bag;
}
}

View File

@ -0,0 +1,110 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class TrackExtractionService
{
private readonly FfprobeService _ffprobe;
public TrackExtractionService(FfprobeService ffprobe)
{
_ffprobe = ffprobe;
}
public async Task<MediaAnalysisResult?> AnalyzeMediaAsync(string filePath, LoggingService logging, CancellationToken ct)
{
var probe = await _ffprobe.AnalyzeAsync(filePath, ct).ConfigureAwait(false);
if (!probe.IsSuccess)
{
logging.Error($"ffprobe: {probe.Error}", "tracks.extract");
return null;
}
var parsed = MediaAnalysisParser.TryParse(probe.Json);
if (parsed is null)
{
logging.Error($"ffprobe JSON не распознан: {Path.GetFileName(filePath)}", "tracks.extract");
}
return parsed;
}
/// <summary>Создаёт <c>[destination]\extract\{audio|subtitles|attachments}</c>, возвращает путь к <c>extract</c>.</summary>
public string? PrepareExtractLayout(string destinationRootFolder) =>
TrackExtractOutputPaths.TryPrepareExtractDirectories(destinationRootFolder);
/// <returns>Успех, stderr ffmpeg.</returns>
public async Task<(bool Success, string StdErr)> RunExtractProcessAsync(IReadOnlyList<string> ffmpegArgumentList, CancellationToken ct)
{
var exe = ResolveFfmpegPath();
if (!File.Exists(exe))
{
return (false, $"ffmpeg не найден: {exe}");
}
var start = new ProcessStartInfo
{
FileName = exe,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var a in ffmpegArgumentList)
{
start.ArgumentList.Add(a);
}
using var p = new Process { StartInfo = start };
p.Start();
using var reg = ct.Register(static state =>
{
try
{
if (state is not Process proc || proc.HasExited)
{
return;
}
proc.Kill(entireProcessTree: true);
}
catch
{
// ignore
}
}, p, useSynchronizationContext: false);
var stderrTask = p.StandardError.ReadToEndAsync(ct);
var stdoutTask = p.StandardOutput.ReadToEndAsync(ct);
try
{
await p.WaitForExitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
try
{
await Task.WhenAll(stderrTask, stdoutTask).ConfigureAwait(false);
}
catch
{
// ignore
}
throw;
}
await Task.WhenAll(stderrTask, stdoutTask).ConfigureAwait(false);
var err = stderrTask.Result;
var ok = p.ExitCode == 0 && !ct.IsCancellationRequested;
return (ok, err.Trim());
}
private static string ResolveFfmpegPath() => Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe");
}

View File

@ -0,0 +1,505 @@
using System.Globalization;
using System.IO;
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Заполняет <see cref="ConversionTaskOverride"/> значениями по умолчанию.</summary>
public static class TrackOverrideSeeder
{
public static void EnsureDefaults(
ConversionTaskOverride o,
MediaAnalysisResult? media,
IReadOnlyList<SidecarFile> side,
ConversionProfileSettingsEntry profile,
bool autoRemoveForeignTracks = false,
IReadOnlyList<ExternalAudioFile>? externalAudio = null,
string? videoPath = null,
SidecarTitleResolver? sidecarTitleResolver = null,
LoggingService? logging = null,
bool disableSubtitleDefault = false)
{
if (o.TrackOverrides.Count > 0)
{
return;
}
SyncTargetFieldsFromProfile(o, profile);
if (media is null)
{
return;
}
var requiresVideoTranscode = ConversionPlanService.RequiresVideoTranscode(media, profile, o);
var primaryVideoIndex = media.PrimaryVideo?.Index;
foreach (var s in media.AllStreams)
{
var a = s.Kind == MediaStreamKind.Data ? TrackActionKind.Remove
: s.Kind == MediaStreamKind.Attachment ? TrackActionKind.Keep
: s.Kind == MediaStreamKind.Video
? (primaryVideoIndex is { } pv && s.Index != pv
? TrackActionKind.Remove
: (requiresVideoTranscode && primaryVideoIndex is { } pv2 && s.Index == pv2
? TrackActionKind.Convert
: TrackActionKind.Keep))
: s.Kind == MediaStreamKind.Audio
? (ConversionPlanService.EmbeddedAudioMatchesProfile(s.CodecName, profile) ? TrackActionKind.Keep : TrackActionKind.Convert)
: s.Kind == MediaStreamKind.Subtitle
? SubtitleCodecRules.DefaultEmbeddedSubtitleAction(s.CodecName, profile.Container)
: TrackActionKind.Keep;
if (autoRemoveForeignTracks
&& s.Kind is MediaStreamKind.Audio or MediaStreamKind.Subtitle
&& IsForeignLanguageForAutoRemove(s.Language))
{
a = TrackActionKind.Remove;
}
o.TrackOverrides.Add(
new TrackOverrideEntry
{
StreamIndex = s.Index,
Source = SourceKind.Embedded,
StreamKind = s.Kind,
Action = a,
Default = s.Kind is MediaStreamKind.Audio or MediaStreamKind.Subtitle ? s.IsDefault : null,
Language = s.Language,
Title = s.Title,
AudioBitrateKbps = s.Kind == MediaStreamKind.Audio && a == TrackActionKind.Convert ? "256 kbps" : null
});
}
var synthIndex = -1000;
var supportsAttachments = SupportsAttachments(profile.Container);
var titleResolver = sidecarTitleResolver ?? new SidecarTitleResolver();
var effectiveVideoPath = string.IsNullOrWhiteSpace(videoPath) ? InferVideoPathFromSidecars(side) : videoPath!;
var subtitleSidecars = side.Where(s => s.IsSubtitle).ToList();
foreach (var sc in side)
{
if (sc.IsAudio)
{
continue;
}
var streamKind = sc.IsFont ? MediaStreamKind.Attachment : MediaStreamKind.Subtitle;
string? externalTitle;
if (streamKind == MediaStreamKind.Subtitle)
{
var ord = subtitleSidecars.FindIndex(s =>
string.Equals(s.FullPath, sc.FullPath, StringComparison.OrdinalIgnoreCase))
+ 1;
var subtitleIndexForFallback = subtitleSidecars.Count > 1 ? ord : 0;
externalTitle = titleResolver.ResolveExternalSubtitleTitle(
effectiveVideoPath,
sc.FullPath,
subtitleIndexForFallback);
LogRecognizedOrFallback(logging, externalTitle);
}
else
{
externalTitle = Path.GetFileName(sc.FullPath);
}
o.TrackOverrides.Add(
new TrackOverrideEntry
{
StreamIndex = synthIndex--,
ExternalPath = sc.FullPath,
Source = SourceKind.External,
StreamKind = streamKind,
Action = streamKind == MediaStreamKind.Attachment && !supportsAttachments ? TrackActionKind.Remove : TrackActionKind.Add,
Default = streamKind == MediaStreamKind.Attachment ? null : true,
Language = streamKind == MediaStreamKind.Attachment ? null : "rus",
Title = externalTitle
});
}
var resolvedAudio = ResolveExternalAudioFiles(side, externalAudio);
var flattened = new List<(ExternalAudioFile file, ExternalAudioStream st)>();
foreach (var audioFile in resolvedAudio)
{
foreach (var st in audioFile.Streams)
{
flattened.Add((audioFile, st));
}
}
var totalExtStreams = flattened.Count;
var firstExternalAudioSet = false;
var sameFileCounters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var commonTitleByFile = BuildCommonTitleByExternalAudioFile(flattened, effectiveVideoPath, titleResolver);
for (var flatIdx = 0; flatIdx < flattened.Count; flatIdx++)
{
var audioFile = flattened[flatIdx].file;
var st = flattened[flatIdx].st;
var isFirstExternalAudio = !firstExternalAudioSet;
if (isFirstExternalAudio)
{
firstExternalAudioSet = true;
}
var oneBasedOrdinalAmongAllExternals = flatIdx + 1;
var details = FormatExternalAudioDetails(st);
var tagTitle = SidecarTitleResolver.NormalizeTitleOrNull(st.TitleFromProbe);
var commonTitle = commonTitleByFile.GetValueOrDefault(audioFile.FullPath);
string displayTitle;
if (tagTitle is not null)
{
displayTitle = tagTitle;
LogRecognizedOrFallback(logging, displayTitle);
}
else if (!string.IsNullOrWhiteSpace(commonTitle))
{
var sameFileOrdinal = sameFileCounters.TryGetValue(audioFile.FullPath, out var current)
? current + 1
: 1;
sameFileCounters[audioFile.FullPath] = sameFileOrdinal;
displayTitle = audioFile.Streams.Count > 1
? $"{commonTitle} {sameFileOrdinal}"
: commonTitle!;
LogRecognizedOrFallback(logging, displayTitle);
}
else
{
displayTitle = titleResolver.ResolveExternalAudioTitle(
effectiveVideoPath,
audioFile.FullPath,
oneBasedOrdinalAmongAllExternals,
streamTags: null);
LogRecognizedOrFallback(logging, displayTitle);
}
o.TrackOverrides.Add(
new TrackOverrideEntry
{
StreamIndex = synthIndex--,
ExternalPath = audioFile.FullPath,
ExternalAudioStreamOrdinal = st.StreamOrdinal,
SameFileExternalAudioStreamCount = audioFile.Streams.Count,
ExternalStreamCodec = st.CodecName,
ExternalStreamDetails = details,
ExternalFfprobeTitle = tagTitle,
Source = SourceKind.External,
StreamKind = MediaStreamKind.Audio,
Action = TrackActionKind.Add,
Default = isFirstExternalAudio,
Language = "rus",
Title = displayTitle,
AudioBitrateKbps = IsAacCodecName(st.CodecName) ? null : "256 kbps"
});
}
ApplySubtitleDefaultRules(o, media, logging, disableSubtitleDefault);
}
/// <summary>RUS (один файл) или RUS 1, RUS 2, … (порядок как в <paramref name="sidecarSubtitleList"/>).</summary>
public static string BuildExternalSubtitleDisplayTitle(int oneBasedIndex, int totalExternalSubtitles)
{
if (totalExternalSubtitles <= 0 || oneBasedIndex < 1)
{
return "RUS";
}
return totalExternalSubtitles == 1 ? "RUS" : $"RUS {oneBasedIndex}";
}
/// <summary>Каноническое title для внешних субтитров: распознанное имя либо fallback RUS / RUS N.</summary>
public static string ExternalSubtitleCanonicalTitle(
IReadOnlyList<TrackOverrideEntry> trackOrder,
TrackOverrideEntry entry,
string videoPath,
SidecarTitleResolver? sidecarTitleResolver = null)
{
if (entry.Source != SourceKind.External
|| entry.StreamKind != MediaStreamKind.Subtitle
|| string.IsNullOrWhiteSpace(entry.ExternalPath))
{
return string.Empty;
}
var externalSubs = trackOrder
.Where(t => t.Source == SourceKind.External
&& t.StreamKind == MediaStreamKind.Subtitle
&& !string.IsNullOrWhiteSpace(t.ExternalPath))
.ToList();
var idx = externalSubs.FindIndex(t =>
string.Equals(t.ExternalPath, entry.ExternalPath, StringComparison.OrdinalIgnoreCase));
var resolver = sidecarTitleResolver ?? new SidecarTitleResolver();
var oneBased = idx >= 0 ? idx + 1 : 1;
var subtitleIndexForFallback = externalSubs.Count > 1 ? oneBased : 0;
return resolver.ResolveExternalSubtitleTitle(videoPath, entry.ExternalPath!, subtitleIndexForFallback);
}
public static string ExternalAudioCanonicalTitleFromEntry(
IReadOnlyList<TrackOverrideEntry> orderedTracks,
TrackOverrideEntry entry,
string videoPath,
SidecarTitleResolver? sidecarTitleResolver = null)
{
if (entry.Source != SourceKind.External || entry.StreamKind != MediaStreamKind.Audio || string.IsNullOrWhiteSpace(entry.ExternalPath))
{
return string.Empty;
}
var list = orderedTracks
.Where(t =>
t.Source == SourceKind.External
&& t.StreamKind == MediaStreamKind.Audio
&& !string.IsNullOrWhiteSpace(t.ExternalPath))
.ToList();
var idx = list.FindIndex(t => ReferenceEquals(t, entry));
if (idx < 0)
{
idx = list.FindIndex(
t => string.Equals(t.ExternalPath, entry.ExternalPath, StringComparison.OrdinalIgnoreCase)
&& t.ExternalAudioStreamOrdinal == entry.ExternalAudioStreamOrdinal);
}
var oneBased = idx >= 0 ? idx + 1 : 1;
var tag = SidecarTitleResolver.NormalizeTitleOrNull(entry.ExternalFfprobeTitle);
if (tag is not null)
{
return tag;
}
var resolver = sidecarTitleResolver ?? new SidecarTitleResolver();
return resolver.ResolveExternalAudioTitle(videoPath, entry.ExternalPath!, oneBased, streamTags: null);
}
private static IReadOnlyList<ExternalAudioFile> ResolveExternalAudioFiles(
IReadOnlyList<SidecarFile> side,
IReadOnlyList<ExternalAudioFile>? supplied)
{
if (supplied is { Count: > 0 })
{
return supplied;
}
var paths = side.Where(s => s.IsAudio).Select(s => s.FullPath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
.ToList();
List<ExternalAudioFile> fallback = [];
foreach (var p in paths)
{
fallback.Add(CreateSingleStreamExternalFallback(p));
}
return fallback;
}
private static ExternalAudioFile CreateSingleStreamExternalFallback(string fullPath)
{
var ext = Path.GetExtension(fullPath);
var stream = new ExternalAudioStream
{
FileFullPath = fullPath,
StreamOrdinal = 0,
CodecName = SidecarDiscoveryService.GuessCodecFromExtension(ext),
TitleFromProbe = null,
Channels = null,
SampleRateHz = null,
BitRateBps = null
};
return new ExternalAudioFile(fullPath, new[] { stream });
}
private static string FormatExternalAudioDetails(ExternalAudioStream st)
{
var ch = st.Channels?.ToString(CultureInfo.InvariantCulture) ?? "?";
var sr = st.SampleRateHz?.ToString(CultureInfo.InvariantCulture) ?? "?";
var bit = st.BitRateBps is { } br ? $"{((br + 500) / 1000)} kbps" : "?";
return $"{st.FileFullPath} | stream #{st.StreamOrdinal + 1} | {ch} ch | {sr} Hz | {bit}";
}
public static void SyncTargetFieldsFromProfile(ConversionTaskOverride o, ConversionProfileSettingsEntry profile)
{
o.TargetContainer = profile.Container;
o.TargetVideo = profile.Video;
o.TargetPixelFormat = profile.PixelFormat;
o.TargetResolution = profile.Resolution;
o.TargetFps = profile.Fps;
o.TargetAudioBitrate = profile.Bitrate;
o.TargetVideoBitrateMode = profile.VideoBitrateMode;
o.TargetVideoBitrateMbps = profile.VideoBitrateMbps;
}
private static bool SupportsAttachments(string? container) =>
!string.IsNullOrWhiteSpace(container) &&
(container.Contains("mkv", StringComparison.OrdinalIgnoreCase) || container.Contains("matro", StringComparison.OrdinalIgnoreCase));
private static bool IsForeignLanguageForAutoRemove(string? language)
{
if (string.IsNullOrWhiteSpace(language))
{
return false;
}
var lang = language.Trim().ToLowerInvariant();
if (lang is "und" or "unknown" or "?")
{
return false;
}
return lang is not ("rus" or "ru");
}
private static bool IsAacCodecName(string? codec) =>
!string.IsNullOrWhiteSpace(codec)
&& codec.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase);
private static string InferVideoPathFromSidecars(IReadOnlyList<SidecarFile> side)
{
if (side.Count == 0)
{
return string.Empty;
}
var dir = Path.GetDirectoryName(side[0].FullPath) ?? string.Empty;
return Path.Combine(dir, "__unknown__.mkv");
}
private static Dictionary<string, string?> BuildCommonTitleByExternalAudioFile(
IReadOnlyList<(ExternalAudioFile file, ExternalAudioStream st)> flattened,
string videoPath,
SidecarTitleResolver resolver)
{
var result = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var grp in flattened.GroupBy(x => x.file.FullPath, StringComparer.OrdinalIgnoreCase))
{
var anyTag = grp.Any(x => SidecarTitleResolver.NormalizeTitleOrNull(x.st.TitleFromProbe) is not null);
if (anyTag)
{
result[grp.Key] = null;
continue;
}
result[grp.Key] = resolver.TryRecognizeSidecarTitle(videoPath, grp.Key);
}
return result;
}
private static void LogRecognizedOrFallback(LoggingService? logging, string title)
{
if (logging is null)
{
return;
}
if (title.StartsWith("RUS", StringComparison.OrdinalIgnoreCase))
{
logging.Debug($"sidecar title fallback: {title}", "conversion.sidecar");
}
else
{
logging.Debug($"sidecar title recognized: {title}", "conversion.sidecar");
}
}
private static void ApplySubtitleDefaultRules(ConversionTaskOverride taskOverride, MediaAnalysisResult media, LoggingService? logging, bool disableSubtitleDefault)
{
var subtitleEntries = taskOverride.TrackOverrides
.Where(t => t.StreamKind == MediaStreamKind.Subtitle)
.ToList();
if (disableSubtitleDefault)
{
foreach (var entry in subtitleEntries)
{
entry.Default = false;
}
return;
}
var forcedCandidates = media.SubtitleStreams
.Where(s => s.IsForced)
.OrderBy(s => s.Index)
.ToList();
var embeddedSubtitleEntries = subtitleEntries
.Where(t => t.Source == SourceKind.Embedded && t.Action != TrackActionKind.Remove)
.ToList();
if (forcedCandidates.Count > 0)
{
var selectedForcedIndex = forcedCandidates[0].Index;
if (forcedCandidates.Count > 1)
{
logging?.Info("Найдено несколько forced-субтитров, default установлен только для первой дорожки.", "subtitle.forced");
}
foreach (var entry in subtitleEntries)
{
entry.Default = false;
}
var selectedEntry = embeddedSubtitleEntries.FirstOrDefault(t => t.StreamIndex == selectedForcedIndex);
if (selectedEntry is null)
{
return;
}
selectedEntry.Default = true;
logging?.Info($"subtitle default set to forced track: #{selectedEntry.StreamIndex}", "subtitle.forced");
return;
}
var defaultEmbeddedAudio = taskOverride.TrackOverrides
.FirstOrDefault(t => t.Source == SourceKind.Embedded
&& t.StreamKind == MediaStreamKind.Audio
&& t.Action != TrackActionKind.Remove
&& t.Default == true);
var defaultAudioIsRus = IsRussianLanguage(defaultEmbeddedAudio?.Language);
var rusFullSubtitleEntries = embeddedSubtitleEntries
.Where(t => IsRussianLanguage(t.Language))
.Where(t =>
{
var stream = media.SubtitleStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
return stream is null || !stream.IsForced;
})
.OrderBy(t => t.StreamIndex)
.ToList();
if (defaultAudioIsRus)
{
foreach (var entry in rusFullSubtitleEntries)
{
entry.Default = false;
}
return;
}
if (rusFullSubtitleEntries.Count == 0)
{
return;
}
foreach (var entry in subtitleEntries)
{
entry.Default = false;
}
rusFullSubtitleEntries[0].Default = true;
logging?.Info($"subtitle default set to first RUS full track: #{rusFullSubtitleEntries[0].StreamIndex}", "subtitle.forced");
}
private static bool IsRussianLanguage(string? language)
{
if (string.IsNullOrWhiteSpace(language))
{
return false;
}
var normalized = language.Trim().ToLowerInvariant();
return normalized is "ru" or "rus" or "russian" or "рус" or "русский";
}
}

View File

@ -0,0 +1,314 @@
using System.Text;
using System.Text.RegularExpressions;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Сопоставление snapshot с текущим файлом: Type + Source + Language + номер дорожки в группе; видео/аудио/субтитры упорядочены по общему рангу.</summary>
public sealed class TrackSettingsSnapshotService
{
private const string LogModule = "conversion.snapshot";
/// <summary>Только пробельные символы (включая переносы).</summary>
private static readonly Regex WhitespaceCollapse = new(@"\s+", RegexOptions.Compiled);
/// <summary>Символы кавычек и типографских кавычек.</summary>
private static readonly Regex QuoteStrip = new(@"[""'`´«»„“”‚‘’]", RegexOptions.Compiled);
/// <summary>Спецсимволы помимо букв/цифр/пробела.</summary>
private static readonly Regex NoiseStrip = new(@"[^\p{L}\p{N}\s]", RegexOptions.Compiled);
private TrackSettingsSnapshot? _lastSnapshot;
private readonly LoggingService? _logging;
public TrackSettingsSnapshotService(LoggingService? logging = null)
{
_logging = logging;
}
/// <summary>
/// batchScopeRootOptional: корень добавления («добавить каталог», общий предок файлов дропом); пустое — сохранять только каталог файла как область точного попадания.
/// </summary>
public void SaveSnapshot(string filePath, IReadOnlyList<TrackSettingsSnapshotItem> trackPlans, string? batchScopeRootOptional)
{
var scopeDirectory = SnapshotScopePaths.GetVideoScopeDirectory(filePath);
var normalizedBatch = string.IsNullOrWhiteSpace(batchScopeRootOptional)
? null
: SnapshotScopePaths.NormalizeScopeDirectory(batchScopeRootOptional);
_lastSnapshot = new TrackSettingsSnapshot
{
FilePath = filePath,
ScopeDirectory = scopeDirectory,
ScopeBatchRoot = normalizedBatch,
Signature = BuildSignature(trackPlans),
Items = trackPlans.ToArray()
};
_logging?.Info($"snapshot сохранен: {filePath} ({trackPlans.Count} дор.), scope:{scopeDirectory}, batchRoot:{(normalizedBatch ?? "-")}", LogModule);
}
public SnapshotApplyResult TryApplySnapshot(
IReadOnlyList<TrackSettingsSnapshotItem> currentTrackPlans,
string currentVideoFullPath,
string? currentBatchScopeRootOptional)
{
if (_lastSnapshot is null)
{
return new SnapshotApplyResult(false, SnapshotApplyDegree.None, SnapshotApplyReason.NoSnapshot, null);
}
if (!IsAllowedSnapshotScope(currentVideoFullPath, currentBatchScopeRootOptional))
{
_logging?.Info(
$"snapshot: область не совпадает — не применяем (текущая папка: {SnapshotScopePaths.GetVideoScopeDirectory(currentVideoFullPath)}; сохранены: {_lastSnapshot.ScopeDirectory}, batch:{_lastSnapshot.ScopeBatchRoot ?? "-"}) — источник {_lastSnapshot.FilePath}",
LogModule);
return new SnapshotApplyResult(false, SnapshotApplyDegree.None, SnapshotApplyReason.ScopeMismatch, null);
}
var snapItems = _lastSnapshot.Items;
var currents = AssignBucketKeys(currentTrackPlans);
var snapMap = BuildSnapshotLookup(snapItems);
var rowResults = new List<TrackMatchResult>(currents.Count);
foreach (var (curItem, bucketKey) in currents)
{
if (snapMap.TryGetValue(bucketKey, out var src))
{
rowResults.Add(new TrackMatchResult
{
CurrentOrder = curItem.Order,
IsMatched = true,
Strategy = MatchingStrategy.OrdinalByTypeSourceLanguage,
HadAmbiguousCandidates = false,
SourceItem = src,
ResolvedAction = src.Action
});
LogTitleSoftMismatchIfAny(curItem, src);
}
else
{
rowResults.Add(new TrackMatchResult
{
CurrentOrder = curItem.Order,
IsMatched = false,
Strategy = MatchingStrategy.None,
HadAmbiguousCandidates = false,
SourceItem = null,
ResolvedAction = curItem.Action
});
}
}
var matchedCount = rowResults.Count(r => r.IsMatched);
SnapshotApplyDegree degree;
if (matchedCount == 0)
{
degree = SnapshotApplyDegree.None;
}
else if (matchedCount == currentTrackPlans.Count
&& matchedCount == snapItems.Count)
{
degree = SnapshotApplyDegree.Full;
}
else
{
degree = SnapshotApplyDegree.Partial;
}
var appliedAny = matchedCount > 0;
WriteApplyLog(currentTrackPlans.Count, snapItems.Count, matchedCount, degree);
return new SnapshotApplyResult(appliedAny, degree, SnapshotApplyReason.Success, rowResults);
}
/// <summary>Не менее двух дорожек с языком <c>und</c>: сопоставление только по очередности внутри группы Kind+Source+und.</summary>
public static bool TracksHaveRiskyMultipleUndTracks(IReadOnlyList<TrackSettingsSnapshotItem> trackPlans) =>
trackPlans
.GroupBy(t => (t.StreamKind, t.Source, Lang: NormalizeLanguage(t.Language)))
.Any(static g => g.Key.Lang == "und" && g.Count() > 1);
private bool IsAllowedSnapshotScope(string currentVideoFullPath, string? currentBatchScopeOptional)
{
if (_lastSnapshot is null)
{
return false;
}
var snapScope = string.IsNullOrWhiteSpace(_lastSnapshot.ScopeDirectory)
? SnapshotScopePaths.GetVideoScopeDirectory(_lastSnapshot.FilePath)
: SnapshotScopePaths.NormalizeScopeDirectory(_lastSnapshot.ScopeDirectory);
var curScope = SnapshotScopePaths.GetVideoScopeDirectory(currentVideoFullPath);
if (string.Equals(curScope, snapScope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var snapBatch = SnapshotScopePaths.NormalizeScopeBatchRootNullable(_lastSnapshot.ScopeBatchRoot);
var curBatch = SnapshotScopePaths.NormalizeScopeBatchRootNullable(currentBatchScopeOptional);
if (snapBatch.Length != 0 && curBatch.Length != 0
&& string.Equals(snapBatch, curBatch, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
/// <summary>Нормализация Title только для доп. сравнения (не ключ сопоставления).</summary>
public static string NormalizeTitleFingerprint(string? title)
{
if (string.IsNullOrWhiteSpace(title))
{
return string.Empty;
}
var sb = new StringBuilder(title.Trim());
sb.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
var s = QuoteStrip.Replace(sb.ToString(), string.Empty);
s = NoiseStrip.Replace(s, string.Empty);
s = WhitespaceCollapse.Replace(s, " ").Trim();
return string.IsNullOrEmpty(s) ? string.Empty : s.ToLowerInvariant();
}
private void LogTitleSoftMismatchIfAny(TrackSettingsSnapshotItem current, TrackSettingsSnapshotItem snapshot)
{
var a = NormalizeTitleFingerprint(snapshot.Title);
var b = NormalizeTitleFingerprint(current.Title);
if (a.Length != 0 && b.Length != 0 && !string.Equals(a, b, StringComparison.Ordinal))
{
_logging?.Debug($"snapshot: title отличается (доп. проверка), ключ не затронут. порядок {current.Order}", LogModule);
}
}
private void WriteApplyLog(int currentCount, int snapCount, int matched, SnapshotApplyDegree degree)
{
var prev = _lastSnapshot?.FilePath ?? "?";
if (degree == SnapshotApplyDegree.Full)
{
_logging?.Info(
$"snapshot: применено по порядку дорожек (полное, {matched}/{currentCount}); источник {prev}",
LogModule);
return;
}
if (degree == SnapshotApplyDegree.Partial)
{
_logging?.Info(
$"snapshot: применено частично ({matched}/{currentCount} дорожек, в snapshot было {snapCount}); источник {prev}",
LogModule);
return;
}
if (currentCount != snapCount)
{
_logging?.Info(
$"snapshot: не применено — не удалось сопоставить ни одной дорожки (snapshot {snapCount}, текущее {currentCount}); источник {prev}",
LogModule);
}
else
{
_logging?.Info(
$"snapshot: не применено — не удалось сопоставить дорожки (тип язык или номер в группе отличается); источник {prev}",
LogModule);
}
}
internal readonly record struct BucketKey(MediaStreamKind Kind, SourceKind Source, string LangNorm, int OrdinalInGroup);
private static IReadOnlyList<(TrackSettingsSnapshotItem Item, BucketKey Key)> AssignBucketKeys(
IReadOnlyList<TrackSettingsSnapshotItem> items)
{
var sorted = items.OrderBy(x => StreamKindRank(x.StreamKind)).ThenBy(x => x.Order).ToList();
var counts = new Dictionary<(MediaStreamKind, SourceKind, string), int>();
var list = new List<(TrackSettingsSnapshotItem Item, BucketKey Key)>(sorted.Count);
foreach (var it in sorted)
{
var lang = NormalizeLanguage(it.Language);
var gKey = (it.StreamKind, it.Source, lang);
var ordinal = counts.GetValueOrDefault(gKey, 0) + 1;
counts[gKey] = ordinal;
var bk = new BucketKey(it.StreamKind, it.Source, lang, ordinal);
list.Add((it, bk));
}
list.Sort((a, b) => a.Item.Order.CompareTo(b.Item.Order));
return list;
}
private static Dictionary<BucketKey, TrackSettingsSnapshotItem> BuildSnapshotLookup(
IReadOnlyList<TrackSettingsSnapshotItem> snapItems)
{
var dict = new Dictionary<BucketKey, TrackSettingsSnapshotItem>();
foreach (var (item, key) in AssignBucketKeys(snapItems))
{
dict[key] = item;
}
return dict;
}
private static int StreamKindRank(MediaStreamKind k) =>
k switch
{
MediaStreamKind.Video => 0,
MediaStreamKind.Audio => 1,
MediaStreamKind.Subtitle => 2,
MediaStreamKind.Attachment => 3,
MediaStreamKind.Data => 4,
_ => 99
};
private static TrackStructureSignature BuildSignature(IReadOnlyList<TrackSettingsSnapshotItem> trackPlans) =>
new()
{
Tracks = trackPlans
.Select(x => new TrackStructureSignatureItem
{
Order = x.Order,
StreamKind = x.StreamKind,
Source = x.Source,
Codec = (x.Codec ?? string.Empty).Trim(),
Language = NormalizeLanguage(x.Language),
Title = NormalizeTitleFingerprint(x.Title)
})
.ToArray()
};
/// <summary>Rus/eng/und и т.д.</summary>
public static string NormalizeLanguage(string? language)
{
var raw = (language ?? string.Empty).Trim();
if (raw.Length == 0)
{
return "und";
}
var t = raw.ToLowerInvariant();
if (t is "und" or "unknown" or "?" or "??")
{
return "und";
}
if (t.Length == 2)
{
return t switch
{
"ru" => "rus",
"en" => "eng",
"uk" => "ukr",
"de" => "deu",
"fr" => "fra",
"es" => "spa",
"it" => "ita",
"ja" => "jpn",
"ko" => "kor",
"zh" => "zho",
_ => t
};
}
return t;
}
}

View File

@ -0,0 +1,56 @@
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class TrackStructureComparer
{
public string BuildSignature(IReadOnlyList<TrackOverrideEntry> tracks)
{
var sb = new StringBuilder(tracks.Count * 40);
for (var i = 0; i < tracks.Count; i++)
{
var t = tracks[i];
if (i > 0)
{
sb.Append("||");
}
sb.Append((int)t.Source);
sb.Append('|');
sb.Append((int)t.StreamKind);
sb.Append('|');
if (t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle)
{
sb.Append(NormalizeToken(t.Language));
sb.Append('|');
sb.Append(NormalizeToken(t.Title));
sb.Append('|');
}
sb.Append(NormalizeToken(ResolveCodecToken(t)));
}
return sb.ToString();
}
private static string ResolveCodecToken(TrackOverrideEntry t)
{
if (!string.IsNullOrWhiteSpace(t.ExternalStreamCodec))
{
return t.ExternalStreamCodec!;
}
return t.StreamKind == MediaStreamKind.Attachment ? "attachment" : string.Empty;
}
private static string NormalizeToken(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().ToLowerInvariant();
return normalized.Replace(" ", " ", StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Globalization;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public static class VideoBitratePolicy
{
public const string Auto = "Auto";
public const string Source = "Source";
public const string Custom = "Custom";
public static IReadOnlyList<string> UiOptions { get; } =
[
Auto,
Source,
"2 Mbps",
"4 Mbps",
"6 Mbps",
"8 Mbps",
"10 Mbps",
"12 Mbps",
"15 Mbps",
"20 Mbps",
Custom
];
public static string NormalizeMode(string? mode) =>
mode switch
{
Source => Source,
Custom => Custom,
_ => TryParseFixedModeKbps(mode) is { } fixedKbps
? $"{fixedKbps / 1000.0:0.###} Mbps".Replace(".000", string.Empty, StringComparison.Ordinal)
: Auto
};
public static int? ResolveTargetKbps(string? mode, double? customMbps, MediaAnalysisResult? media)
{
var normalized = NormalizeMode(mode);
if (normalized == Auto)
{
return null;
}
if (normalized == Source)
{
return media?.SourceVideoBitrateBps is { } bps && bps > 0
? Math.Max(1, (int)Math.Round(bps / 1000.0, MidpointRounding.AwayFromZero))
: null;
}
if (normalized == Custom)
{
return customMbps is { } mbps && mbps > 0
? Math.Max(1, (int)Math.Round(mbps * 1000.0, MidpointRounding.AwayFromZero))
: null;
}
return TryParseFixedModeKbps(normalized);
}
public static int? TryParseFixedModeKbps(string? mode)
{
if (string.IsNullOrWhiteSpace(mode))
{
return null;
}
var m = mode.Trim();
if (m.Equals(Auto, StringComparison.OrdinalIgnoreCase)
|| m.Equals(Source, StringComparison.OrdinalIgnoreCase)
|| m.Equals(Custom, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!m.EndsWith("Mbps", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var number = m[..^4].Trim();
if (!double.TryParse(number.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var mbps) || mbps <= 0)
{
return null;
}
return Math.Max(1, (int)Math.Round(mbps * 1000.0, MidpointRounding.AwayFromZero));
}
public static string FormatCurrentSource(long? bps)
{
if (bps is not { } value || value <= 0)
{
return "Текущее: неизвестно";
}
var mbps = value / 1_000_000d;
return $"Текущее: {mbps:0.###} Mbps";
}
}

View File

@ -0,0 +1,392 @@
using System.Globalization;
using System.IO;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed record SidecarAnalysisResult(
string VideoPath,
IReadOnlyList<SidecarFile> Sidecars,
IReadOnlyList<ExternalAudioFile> ExternalAudioFiles);
public sealed class VideoInfoSummaryService
{
private readonly SidecarTitleResolver _titleResolver = new();
public string BuildSummary(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
{
var lines = new List<string>
{
$"Формат: {NormalizeContainer(media.ContainerFormat, sidecars.VideoPath)}",
"Видео:",
$"\t- {FormatVideoLine(media)}",
"Аудио:"
};
var audioLines = BuildAudioLines(media, sidecars);
if (audioLines.Count == 0)
{
lines.Add("\t- ?");
}
else
{
foreach (var audioLine in audioLines)
{
lines.Add("\t- " + audioLine);
}
}
lines.Add("Субтитры:");
var subtitleLines = BuildSubtitleLines(media, sidecars);
if (subtitleLines.Count == 0)
{
lines.Add("\t- ?");
}
else
{
foreach (var subtitleLine in subtitleLines)
{
lines.Add("\t- " + subtitleLine);
}
}
var attachmentsLine = FormatAttachmentsLine(media);
if (!string.IsNullOrWhiteSpace(attachmentsLine))
{
lines.Add(attachmentsLine);
}
return string.Join(Environment.NewLine, lines);
}
public string NormalizeContainer(string? formatName, string videoPath)
{
var format = (formatName ?? string.Empty).ToLowerInvariant();
var ext = Path.GetExtension(videoPath).ToLowerInvariant();
if (format.Contains("matroska", StringComparison.Ordinal) || format.Contains("webm", StringComparison.Ordinal))
{
return ext switch
{
".webm" => "WebM",
_ => "MKV"
};
}
if (format.Contains("mov", StringComparison.Ordinal)
|| format.Contains("mp4", StringComparison.Ordinal)
|| format.Contains("m4a", StringComparison.Ordinal)
|| format.Contains("3gp", StringComparison.Ordinal)
|| format.Contains("3g2", StringComparison.Ordinal)
|| format.Contains("mj2", StringComparison.Ordinal))
{
return "MP4";
}
if (format.Contains("avi", StringComparison.Ordinal))
{
return "AVI";
}
if (format.Contains("mpegts", StringComparison.Ordinal))
{
return "TS";
}
return format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault()?.ToUpperInvariant()
?? "?";
}
public string FormatVideoLine(MediaAnalysisResult media)
{
var v = media.PrimaryVideo;
if (v is null)
{
return "?";
}
var codec = NormalizeVideoCodec(v);
var profile = NormalizeVideoProfile(v);
var resolution = v.Width is { } w && v.Height is { } h ? $"{w}x{h}" : "?";
var bitrate = FormatBitrate(v.BitRateBps ?? media.FormatBitRateBps);
var fps = FormatFps(v.AverageFrameRate ?? v.FrameRate);
return $"{codec}{profile}, {resolution}, {bitrate}, {fps}";
}
public string FormatAudioLine(string lang, bool isExternal, int index, string codec, int? sampleRateHz, int? channels, long? bitrateBps, string? title)
{
var ext = isExternal ? "(ext)" : string.Empty;
var idx = index > 0 ? $" {index}" : string.Empty;
var titlePart = string.IsNullOrWhiteSpace(title) ? string.Empty : $" [{title.Trim()}]";
var channelPart = channels is { } ch ? $"{ch}ch" : "?ch";
return $"{lang}{ext}{idx}: {codec}, {FormatSampleRate(sampleRateHz)}, {channelPart}, {FormatBitrate(bitrateBps)}{titlePart}";
}
public string FormatSubtitleLine(string lang, bool isExternal, int index, string codec, string? title)
{
var ext = isExternal ? "(ext)" : string.Empty;
var idx = index > 0 ? $" {index}" : string.Empty;
var titlePart = string.IsNullOrWhiteSpace(title) ? string.Empty : $" [{title.Trim()}]";
return $"{lang}{ext}{idx}: {codec}{titlePart}";
}
public string? FormatAttachmentsLine(MediaAnalysisResult media)
{
var attachments = media.AllStreams.Where(s => s.Kind == MediaStreamKind.Attachment).ToList();
if (attachments.Count == 0)
{
return null;
}
var fontCount = attachments.Count(IsFontAttachment);
return fontCount > 0
? $"Attachments: {attachments.Count} files, fonts: {fontCount}"
: $"Attachments: {attachments.Count} files";
}
private List<string> BuildAudioLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
{
var lines = new List<string>();
var counters = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var a in media.AudioStreams.OrderBy(x => x.Index))
{
var lang = NormalizeLanguage(a.Language, external: false);
var key = $"in:{lang}";
var nextIndex = AddAndGetIndex(counters, key);
var displayIndex = nextIndex > 1 ? nextIndex : 0;
lines.Add(FormatAudioLine(
lang,
isExternal: false,
index: displayIndex,
NormalizeAudioCodec(a.CodecName),
a.SampleRateHz,
a.Channels,
a.BitRateBps,
a.Title));
}
var externalStreams = sidecars.ExternalAudioFiles
.SelectMany(f => f.Streams.Select(s => (file: f, stream: s)))
.ToList();
var rusFallbackIndex = 0;
foreach (var e in externalStreams)
{
var lang = "RUS";
var key = $"ext:{lang}";
var nextIndex = AddAndGetIndex(counters, key);
var displayIndex = nextIndex > 1 ? nextIndex : 1;
string? title = SidecarTitleResolver.NormalizeTitleOrNull(e.stream.TitleFromProbe);
if (title is null)
{
title = _titleResolver.TryRecognizeSidecarTitle(sidecars.VideoPath, e.file.FullPath);
}
if (title is null)
{
rusFallbackIndex++;
title = SidecarTitleResolver.BuildRusAudioFallback(rusFallbackIndex);
}
lines.Add(FormatAudioLine(
lang,
isExternal: true,
index: displayIndex,
NormalizeAudioCodec(e.stream.CodecName),
e.stream.SampleRateHz,
e.stream.Channels,
e.stream.BitRateBps,
title));
}
return lines;
}
private List<string> BuildSubtitleLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
{
var lines = new List<string>();
var counters = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var s in media.SubtitleStreams.OrderBy(x => x.Index))
{
var lang = NormalizeLanguage(s.Language, external: false);
var key = $"in:{lang}";
var nextIndex = AddAndGetIndex(counters, key);
var displayIndex = nextIndex > 1 ? nextIndex : 0;
lines.Add(FormatSubtitleLine(lang, false, displayIndex, NormalizeSubtitleCodec(s.CodecName), s.Title));
}
var externalSubs = sidecars.Sidecars.Where(s => s.IsSubtitle).OrderBy(s => s.FileName, StringComparer.OrdinalIgnoreCase).ToList();
var fallbackRusIdx = 0;
foreach (var sub in externalSubs)
{
var lang = "RUS";
var key = $"ext:{lang}";
var nextIndex = AddAndGetIndex(counters, key);
var displayIndex = nextIndex > 1 ? nextIndex : 0;
var title = _titleResolver.TryRecognizeSidecarTitle(sidecars.VideoPath, sub.FullPath);
if (string.IsNullOrWhiteSpace(title))
{
fallbackRusIdx++;
title = SidecarTitleResolver.BuildRusSubtitleFallback(fallbackRusIdx);
}
lines.Add(FormatSubtitleLine(lang, true, displayIndex, NormalizeSubtitleCodecByExtension(sub.FullPath), title));
}
return lines;
}
private static int AddAndGetIndex(Dictionary<string, int> counters, string key)
{
counters.TryGetValue(key, out var current);
current++;
counters[key] = current;
return current;
}
private static string NormalizeLanguage(string? language, bool external)
{
if (external && string.IsNullOrWhiteSpace(language))
{
return "RUS";
}
var l = (language ?? string.Empty).Trim().ToLowerInvariant();
return l switch
{
"" or "unknown" or "und" or "?" => "UND",
"rus" or "ru" => "RUS",
"jpn" or "ja" => "JPN",
"eng" or "en" => "ENG",
_ => l.ToUpperInvariant()
};
}
private static string NormalizeVideoCodec(MediaStreamInfo v)
{
var codec = (v.CodecName ?? string.Empty).Trim().ToLowerInvariant();
if (codec.Contains("h264", StringComparison.Ordinal) || codec.Contains("avc", StringComparison.Ordinal))
{
return (v.Encoder ?? string.Empty).Contains("x264", StringComparison.OrdinalIgnoreCase) ? "x264" : "H.264";
}
if (codec.Contains("hevc", StringComparison.Ordinal) || codec.Contains("h265", StringComparison.Ordinal))
{
return "H.265";
}
return string.IsNullOrWhiteSpace(v.CodecName) ? "?" : v.CodecName.ToUpperInvariant();
}
private static string NormalizeVideoProfile(MediaStreamInfo v)
{
var profile = (v.Profile ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(profile))
{
if ((v.PixelFormat ?? string.Empty).Contains("10", StringComparison.OrdinalIgnoreCase))
{
return " (10-bit)";
}
return string.Empty;
}
if (profile.Contains("high 10", StringComparison.OrdinalIgnoreCase))
{
return " (Hi10p)";
}
return $" ({profile})";
}
private static string NormalizeAudioCodec(string? codec)
{
var c = (codec ?? string.Empty).Trim().ToLowerInvariant();
return c switch
{
"aac" => "AAC",
"ac3" => "AC3",
"eac3" => "EAC3",
"dts" => "DTS",
"flac" => "FLAC",
"mp3" => "MP3",
"mp2" => "MP2",
"opus" => "OPUS",
_ => string.IsNullOrWhiteSpace(codec) ? "?" : codec.ToUpperInvariant()
};
}
private static string NormalizeSubtitleCodec(string? codec)
{
var c = (codec ?? string.Empty).Trim().ToLowerInvariant();
return c switch
{
"subrip" => "SRT",
"ass" => "ASS",
"ssa" => "SSA",
"hdmv_pgs_subtitle" => "PGS",
"dvd_subtitle" => "VobSub",
"mov_text" => "MOV_TEXT",
_ => string.IsNullOrWhiteSpace(codec) ? "?" : codec.ToUpperInvariant()
};
}
private static string NormalizeSubtitleCodecByExtension(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".srt" => "SRT",
".ass" => "ASS",
".ssa" => "SSA",
".sup" => "PGS",
".sub" or ".idx" => "VobSub",
".vtt" => "WEBVTT",
_ => ext.TrimStart('.').ToUpperInvariant()
};
}
private static string FormatBitrate(long? bps)
{
if (bps is not { } value || value <= 0)
{
return "?";
}
var kbps = value / 1000d;
if (kbps >= 10000)
{
return $"{FormatDecimal(kbps / 1000d)} Mbps";
}
return $"{FormatDecimal(kbps)} kbps";
}
private static string FormatSampleRate(int? sampleRateHz) => sampleRateHz is { } hz && hz > 0 ? $"{hz} Hz" : "? Hz";
private static string FormatFps(double? fps) => fps is { } value && value > 0 ? $"{FormatDecimal(value)} fps" : "? fps";
private static string FormatDecimal(double value) =>
value.ToString("0.###", CultureInfo.GetCultureInfo("ru-RU"));
private static bool IsFontAttachment(MediaStreamInfo stream)
{
if (stream.Kind != MediaStreamKind.Attachment)
{
return false;
}
var mime = (stream.AttachmentDeclaredMimeType ?? string.Empty).ToLowerInvariant();
if (mime.Contains("font", StringComparison.Ordinal)
|| mime.Contains("truetype", StringComparison.Ordinal)
|| mime.Contains("opentype", StringComparison.Ordinal)
|| mime.Contains("sfnt", StringComparison.Ordinal))
{
return true;
}
var name = stream.AttachmentDeclaredFileName ?? string.Empty;
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".ttf" or ".otf" or ".ttc" or ".otc";
}
}

View File

@ -0,0 +1,211 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Cursor / VS Code "Light+ / Light Modern" inspired — нейтральный workbench, белые панели, #0078D4 accent -->
<Color x:Key="C.Workbench">#F3F3F3</Color>
<Color x:Key="C.Editor">#FFFFFFFF</Color>
<Color x:Key="C.Sidebar">#EAEAEA</Color>
<Color x:Key="C.Border">#D4D4D4</Color>
<Color x:Key="C.BorderSubtle">#E1E1E1</Color>
<Color x:Key="C.Text">#1A1A1A</Color>
<Color x:Key="C.Muted">#6B6B6B</Color>
<Color x:Key="C.Caption">#6E6E6E</Color>
<Color x:Key="C.Placeholder">#8C8C8C</Color>
<Color x:Key="C.TextDisabled">#A8A8A8</Color>
<Color x:Key="C.Accent">#0078D4</Color>
<Color x:Key="C.AccentHover">#006CBD</Color>
<Color x:Key="C.AccentPressed">#005A9E</Color>
<Color x:Key="C.DangerText">#A1260D</Color>
<Color x:Key="C.DangerBorder">#E0B0A8</Color>
<Color x:Key="C.DangerBackground">#FFF4F2</Color>
<Color x:Key="C.DangerBgHover">#FDECE8</Color>
<Color x:Key="C.ErrorText">#A1260D</Color>
<Color x:Key="C.Success">#0B6E2F</Color>
<!-- Фон строки очереди «Готово» и заливка общего ProgressBar конвертации -->
<Color x:Key="C.QueueRowDone">#DDF5DD</Color>
<Color x:Key="C.DataHeader">#F0F0F0</Color>
<Color x:Key="C.DataRowA">#FFFFFFFF</Color>
<Color x:Key="C.DataRowB">#FAFAFA</Color>
<Color x:Key="C.DataRowHover">#E8E8E8</Color>
<Color x:Key="C.DataRowSelected">#D6E8FC</Color>
<Color x:Key="C.DataRowSelectHover">#B0D0F2</Color>
<Color x:Key="C.DataLine">#E1E1E1</Color>
<Color x:Key="C.LogBg">#F5F5F5</Color>
<Color x:Key="C.LogBorder">#D4D4D4</Color>
<Color x:Key="C.LogText">#1E1E1E</Color>
<Color x:Key="C.Toolbar">#ECECEC</Color>
<Color x:Key="C.Status">#EAEAEA</Color>
<Color x:Key="C.TabStrip">#F0F0F0</Color>
<Color x:Key="C.TabHover">#E0E0E0</Color>
<Color x:Key="C.Track">#E5E5E5</Color>
<!-- Как в Visual Studio: вторичные кнопки, scroll/slider, hover полей -->
<Color x:Key="C.ButtonHover">#E1E1E1</Color>
<Color x:Key="C.ButtonPressed">#C8C8C8</Color>
<!-- Единая палитра кнопок (Primary / Secondary / Danger / Ghost) -->
<Color x:Key="C.Btn.Primary">#2D7DFF</Color>
<Color x:Key="C.Btn.PrimaryHover">#1F6FE5</Color>
<Color x:Key="C.Btn.PrimaryPressed">#1856CC</Color>
<Color x:Key="C.Btn.SecondaryBg">#F5F5F5</Color>
<Color x:Key="C.Btn.SecondaryBorder">#CCCCCC</Color>
<Color x:Key="C.Btn.SecondaryHover">#EAEAEA</Color>
<Color x:Key="C.Btn.SecondaryPressed">#E3E3E3</Color>
<Color x:Key="C.Btn.DangerHover">#FFE5E5</Color>
<Color x:Key="C.Btn.DangerPressed">#FFDADA</Color>
<Color x:Key="C.Btn.GhostBorder">#DDDDDD</Color>
<Color x:Key="C.Btn.GhostHover">#F0F0F0</Color>
<Color x:Key="C.ControlHover">#E8E8E8</Color>
<Color x:Key="C.DangerPressed">#E0B0B0</Color>
<Color x:Key="C.ScrollBarThumb">#A6A6A6</Color>
<Color x:Key="C.ScrollBarThumbHover">#7A7A7A</Color>
<Color x:Key="C.ScrollBarTrack">#F0F0F0</Color>
<Color x:Key="C.SliderThumb">#2C2C2C</Color>
<Color x:Key="C.InputBackground">#FFFFFFFF</Color>
<Color x:Key="C.InputBorder">#D4D4D4</Color>
<Color x:Key="C.SelectedText">#FFFFFF</Color>
<Color x:Key="C.InputDisabledBg">#F0F0F0</Color>
<Color x:Key="C.ComboHoverBg">#FAFAFA</Color>
<Color x:Key="C.QueueDropOverlay">#0D2D7CE5</Color>
<Color x:Key="C.MergeDropOverlay">#1A0B79FF</Color>
<Color x:Key="C.QueueRunning">#D9ECFF</Color>
<Color x:Key="C.QueueRunningBorder">#5A8FD8</Color>
<Color x:Key="C.QueueCopying">#E8D9FF</Color>
<Color x:Key="C.QueueReplacing">#FFE8CC</Color>
<Color x:Key="C.QueueError">#FFD9D9</Color>
<Color x:Key="C.QueueCancelled">#ECECEC</Color>
<Color x:Key="C.PlanNonSkipText">#7A4E1D</Color>
<Color x:Key="C.PlanSkipText">#1A1A1A</Color>
<Color x:Key="C.Track.DefaultBg">#EAF6EA</Color>
<Color x:Key="C.Track.ConvertBg">#FFF7D6</Color>
<Color x:Key="C.Track.RemoveBg">#FBE3E6</Color>
<Color x:Key="C.Track.NormalBg">#FFFFFF</Color>
<Color x:Key="C.Track.HoverDefault">#E3F0E3</Color>
<Color x:Key="C.Track.HoverConvert">#FFF2CC</Color>
<Color x:Key="C.Track.HoverRemove">#F7D8DC</Color>
<Color x:Key="C.Track.SelDefault">#DEEBDE</Color>
<Color x:Key="C.Track.SelConvert">#FFECC2</Color>
<Color x:Key="C.Track.SelRemove">#F2D2D7</Color>
<Color x:Key="C.Track.SelNormal">#ECEEF1</Color>
<Color x:Key="C.Track.SelHoverDefault">#D6E5D6</Color>
<Color x:Key="C.Track.SelHoverConvert">#FFE6B0</Color>
<Color x:Key="C.Track.SelHoverRemove">#EEC8CE</Color>
<Color x:Key="C.Track.SelHoverNormal">#E3E5E9</Color>
<Color x:Key="C.Track.SelectionStripe">#B0B8C1</Color>
<Color x:Key="C.Track.ConflictBorder">#D57A80</Color>
<Color x:Key="C.Toast.SuccessBg">#EAF6EA</Color>
<Color x:Key="C.Toast.SuccessBorder">#4CAF50</Color>
<Color x:Key="C.Toast.SuccessIcon">#388E3C</Color>
<Color x:Key="C.Toast.WarningBg">#FFF4CC</Color>
<Color x:Key="C.Toast.WarningBorder">#E0A800</Color>
<Color x:Key="C.Toast.WarningIcon">#BF8F00</Color>
<Color x:Key="C.Toast.ErrorBg">#FDECEC</Color>
<Color x:Key="C.Toast.ErrorBorder">#D9534F</Color>
<Color x:Key="C.Toast.ErrorIcon">#D9534F</Color>
<Color x:Key="C.LogDebug">#777777</Color>
<Color x:Key="C.LogInfo">#000000</Color>
<Color x:Key="C.LogWarning">#B26A00</Color>
<Color x:Key="C.LogError">#C62828</Color>
<Color x:Key="C.LogSelectionBg">#0078D7</Color>
<Color x:Key="C.LogSelectionText">#FFFFFF</Color>
<SolidColorBrush x:Key="Ui.Brush.Window" Color="{StaticResource C.Workbench}" />
<SolidColorBrush x:Key="Ui.Brush.Surface" Color="{StaticResource C.Editor}" />
<SolidColorBrush x:Key="Ui.Brush.SurfaceSubtle" Color="{StaticResource C.TabStrip}" />
<SolidColorBrush x:Key="Ui.Brush.Border" Color="{StaticResource C.Border}" />
<SolidColorBrush x:Key="Ui.Brush.BorderSubtle" Color="{StaticResource C.BorderSubtle}" />
<SolidColorBrush x:Key="Ui.Brush.Text" Color="{StaticResource C.Text}" />
<SolidColorBrush x:Key="Ui.Brush.Muted" Color="{StaticResource C.Muted}" />
<SolidColorBrush x:Key="Ui.Brush.Caption" Color="{StaticResource C.Caption}" />
<SolidColorBrush x:Key="Ui.Brush.Placeholder" Color="{StaticResource C.Placeholder}" />
<SolidColorBrush x:Key="Ui.Brush.TextDisabled" Color="{StaticResource C.TextDisabled}" />
<SolidColorBrush x:Key="Ui.Brush.Accent" Color="{StaticResource C.Accent}" />
<SolidColorBrush x:Key="Ui.Brush.AccentHover" Color="{StaticResource C.AccentHover}" />
<SolidColorBrush x:Key="Ui.Brush.AccentPressed" Color="{StaticResource C.AccentPressed}" />
<SolidColorBrush x:Key="Ui.Brush.DangerText" Color="{StaticResource C.DangerText}" />
<SolidColorBrush x:Key="Ui.Brush.DangerBorder" Color="{StaticResource C.DangerBorder}" />
<SolidColorBrush x:Key="Ui.Brush.DangerBackground" Color="{StaticResource C.DangerBackground}" />
<SolidColorBrush x:Key="Ui.Brush.DangerBgHover" Color="{StaticResource C.DangerBgHover}" />
<SolidColorBrush x:Key="Ui.Brush.ErrorText" Color="{StaticResource C.ErrorText}" />
<SolidColorBrush x:Key="Ui.Brush.Success" Color="{StaticResource C.Success}" />
<SolidColorBrush x:Key="Ui.Brush.QueueRowDone" Color="{StaticResource C.QueueRowDone}" />
<SolidColorBrush x:Key="Ui.Brush.DataHeader" Color="{StaticResource C.DataHeader}" />
<SolidColorBrush x:Key="Ui.Brush.DataRowA" Color="{StaticResource C.DataRowA}" />
<SolidColorBrush x:Key="Ui.Brush.DataRowB" Color="{StaticResource C.DataRowB}" />
<SolidColorBrush x:Key="Ui.Brush.DataRowHover" Color="{StaticResource C.DataRowHover}" />
<SolidColorBrush x:Key="Ui.Brush.DataRowSelected" Color="{StaticResource C.DataRowSelected}" />
<SolidColorBrush x:Key="Ui.Brush.DataRowSelectHover" Color="{StaticResource C.DataRowSelectHover}" />
<SolidColorBrush x:Key="Ui.Brush.DataLine" Color="{StaticResource C.DataLine}" />
<SolidColorBrush x:Key="Ui.Brush.LogBg" Color="{StaticResource C.LogBg}" />
<SolidColorBrush x:Key="Ui.Brush.LogBorder" Color="{StaticResource C.LogBorder}" />
<SolidColorBrush x:Key="Ui.Brush.LogText" Color="{StaticResource C.LogText}" />
<SolidColorBrush x:Key="Ui.Brush.Toolbar" Color="{StaticResource C.Toolbar}" />
<SolidColorBrush x:Key="Ui.Brush.StatusBar" Color="{StaticResource C.Status}" />
<SolidColorBrush x:Key="Ui.Brush.TabStrip" Color="{StaticResource C.TabStrip}" />
<SolidColorBrush x:Key="Ui.Brush.TabHover" Color="{StaticResource C.TabHover}" />
<SolidColorBrush x:Key="Ui.Brush.Track" Color="{StaticResource C.Track}" />
<SolidColorBrush x:Key="Ui.Brush.Transparent" Color="Transparent" />
<SolidColorBrush x:Key="Ui.Brush.ButtonHover" Color="{StaticResource C.ButtonHover}" />
<SolidColorBrush x:Key="Ui.Brush.ButtonPressed" Color="{StaticResource C.ButtonPressed}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.Primary" Color="{StaticResource C.Btn.Primary}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.PrimaryHover" Color="{StaticResource C.Btn.PrimaryHover}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.PrimaryPressed" Color="{StaticResource C.Btn.PrimaryPressed}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryBg" Color="{StaticResource C.Btn.SecondaryBg}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryBorder" Color="{StaticResource C.Btn.SecondaryBorder}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryHover" Color="{StaticResource C.Btn.SecondaryHover}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryPressed" Color="{StaticResource C.Btn.SecondaryPressed}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.DangerHover" Color="{StaticResource C.Btn.DangerHover}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.DangerPressed" Color="{StaticResource C.Btn.DangerPressed}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.GhostBorder" Color="{StaticResource C.Btn.GhostBorder}" />
<SolidColorBrush x:Key="Ui.Brush.Btn.GhostHover" Color="{StaticResource C.Btn.GhostHover}" />
<SolidColorBrush x:Key="Ui.Brush.ControlHover" Color="{StaticResource C.ControlHover}" />
<SolidColorBrush x:Key="Ui.Brush.DangerPressed" Color="{StaticResource C.DangerPressed}" />
<SolidColorBrush x:Key="Ui.Brush.ScrollBarThumb" Color="{StaticResource C.ScrollBarThumb}" />
<SolidColorBrush x:Key="Ui.Brush.ScrollBarThumbHover" Color="{StaticResource C.ScrollBarThumbHover}" />
<SolidColorBrush x:Key="Ui.Brush.ScrollBarTrack" Color="{StaticResource C.ScrollBarTrack}" />
<SolidColorBrush x:Key="Ui.Brush.SliderThumb" Color="{StaticResource C.SliderThumb}" />
<SolidColorBrush x:Key="Ui.Brush.InputBackground" Color="{StaticResource C.InputBackground}" />
<SolidColorBrush x:Key="Ui.Brush.InputBorder" Color="{StaticResource C.InputBorder}" />
<SolidColorBrush x:Key="Ui.Brush.SelectedText" Color="{StaticResource C.SelectedText}" />
<SolidColorBrush x:Key="Ui.Brush.InputDisabledBg" Color="{StaticResource C.InputDisabledBg}" />
<SolidColorBrush x:Key="Ui.Brush.ComboHoverBg" Color="{StaticResource C.ComboHoverBg}" />
<SolidColorBrush x:Key="Ui.Brush.QueueDropOverlay" Color="{StaticResource C.QueueDropOverlay}" />
<SolidColorBrush x:Key="Ui.Brush.MergeDropOverlay" Color="{StaticResource C.MergeDropOverlay}" />
<SolidColorBrush x:Key="Ui.Brush.QueueRunning" Color="{StaticResource C.QueueRunning}" />
<SolidColorBrush x:Key="Ui.Brush.QueueRunningBorder" Color="{StaticResource C.QueueRunningBorder}" />
<SolidColorBrush x:Key="Ui.Brush.QueueCopying" Color="{StaticResource C.QueueCopying}" />
<SolidColorBrush x:Key="Ui.Brush.QueueReplacing" Color="{StaticResource C.QueueReplacing}" />
<SolidColorBrush x:Key="Ui.Brush.QueueError" Color="{StaticResource C.QueueError}" />
<SolidColorBrush x:Key="Ui.Brush.QueueCancelled" Color="{StaticResource C.QueueCancelled}" />
<SolidColorBrush x:Key="Ui.Brush.PlanNonSkipText" Color="{StaticResource C.PlanNonSkipText}" />
<SolidColorBrush x:Key="Ui.Brush.PlanSkipText" Color="{StaticResource C.PlanSkipText}" />
<SolidColorBrush x:Key="Ui.Brush.Track.DefaultBg" Color="{StaticResource C.Track.DefaultBg}" />
<SolidColorBrush x:Key="Ui.Brush.Track.ConvertBg" Color="{StaticResource C.Track.ConvertBg}" />
<SolidColorBrush x:Key="Ui.Brush.Track.RemoveBg" Color="{StaticResource C.Track.RemoveBg}" />
<SolidColorBrush x:Key="Ui.Brush.Track.NormalBg" Color="{StaticResource C.Track.NormalBg}" />
<SolidColorBrush x:Key="Ui.Brush.Track.HoverDefault" Color="{StaticResource C.Track.HoverDefault}" />
<SolidColorBrush x:Key="Ui.Brush.Track.HoverConvert" Color="{StaticResource C.Track.HoverConvert}" />
<SolidColorBrush x:Key="Ui.Brush.Track.HoverRemove" Color="{StaticResource C.Track.HoverRemove}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelDefault" Color="{StaticResource C.Track.SelDefault}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelConvert" Color="{StaticResource C.Track.SelConvert}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelRemove" Color="{StaticResource C.Track.SelRemove}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelNormal" Color="{StaticResource C.Track.SelNormal}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverDefault" Color="{StaticResource C.Track.SelHoverDefault}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverConvert" Color="{StaticResource C.Track.SelHoverConvert}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverRemove" Color="{StaticResource C.Track.SelHoverRemove}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverNormal" Color="{StaticResource C.Track.SelHoverNormal}" />
<SolidColorBrush x:Key="Ui.Brush.Track.SelectionStripe" Color="{StaticResource C.Track.SelectionStripe}" />
<SolidColorBrush x:Key="Ui.Brush.Track.ConflictBorder" Color="{StaticResource C.Track.ConflictBorder}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.SuccessBg" Color="{StaticResource C.Toast.SuccessBg}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.SuccessBorder" Color="{StaticResource C.Toast.SuccessBorder}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.SuccessIcon" Color="{StaticResource C.Toast.SuccessIcon}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.WarningBg" Color="{StaticResource C.Toast.WarningBg}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.WarningBorder" Color="{StaticResource C.Toast.WarningBorder}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.WarningIcon" Color="{StaticResource C.Toast.WarningIcon}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.ErrorBg" Color="{StaticResource C.Toast.ErrorBg}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.ErrorBorder" Color="{StaticResource C.Toast.ErrorBorder}" />
<SolidColorBrush x:Key="Ui.Brush.Toast.ErrorIcon" Color="{StaticResource C.Toast.ErrorIcon}" />
<SolidColorBrush x:Key="Ui.Brush.LogDebug" Color="{StaticResource C.LogDebug}" />
<SolidColorBrush x:Key="Ui.Brush.LogInfo" Color="{StaticResource C.LogInfo}" />
<SolidColorBrush x:Key="Ui.Brush.LogWarning" Color="{StaticResource C.LogWarning}" />
<SolidColorBrush x:Key="Ui.Brush.LogError" Color="{StaticResource C.LogError}" />
<SolidColorBrush x:Key="Ui.Brush.LogSelectionBg" Color="{StaticResource C.LogSelectionBg}" />
<SolidColorBrush x:Key="Ui.Brush.LogSelectionText" Color="{StaticResource C.LogSelectionText}" />
</ResourceDictionary>

View File

@ -0,0 +1,942 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:System.Windows.Controls;assembly=PresentationFramework">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="UiLayout.xaml" />
</ResourceDictionary.MergedDictionaries>
<Style TargetType="Window">
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Window}" />
<Setter Property="UseLayoutRounding" Value="True" />
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
</Style>
<Style x:Key="UiTextH1" TargetType="TextBlock">
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="LineHeight" Value="26" />
</Style>
<Style x:Key="UiTextH2" TargetType="TextBlock">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="LineHeight" Value="20" />
</Style>
<Style x:Key="UiTextBody" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="LineHeight" Value="18" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Style>
<Style x:Key="UiTextCaption" TargetType="TextBlock">
<Setter Property="FontSize" Value="11" />
<Setter Property="LineHeight" Value="15" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Caption}" />
</Style>
<Style x:Key="UiTextMuted" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="LineHeight" Value="18" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
</Style>
<Style x:Key="UiTextError" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="LineHeight" Value="18" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.ErrorText}" />
</Style>
<Style x:Key="UiTextSuccess" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="LineHeight" Value="18" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Success}" />
</Style>
<!-- Базовый цвет для "обычных" TextBlock/Label без явного стиля -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Style>
<Style TargetType="Label">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Style>
<Style x:Key="UiSectionCard" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.SurfaceSubtle}" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="14,12" />
</Style>
<!-- Primary: основное действие -->
<Style x:Key="UiButtonPrimary" TargetType="Button">
<Style.Resources>
<Style TargetType="AccessText">
<Setter Property="Foreground" Value="White" />
</Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="White" />
</Style>
</Style.Resources>
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="TextElement.Foreground" Value="White" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Padding" Value="10,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="12" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
CornerRadius="6" SnapsToDevicePixels="True">
<Border x:Name="Bd" CornerRadius="5" Margin="0" SnapsToDevicePixels="True" BorderThickness="0"
Background="{DynamicResource Ui.Brush.Btn.Primary}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"
RecognizesAccessKey="True"
TextElement.Foreground="White" />
</Border>
</Border>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsEnabled" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.PrimaryHover}" />
</MultiTrigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.PrimaryPressed}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Secondary -->
<Style x:Key="UiButtonSecondary" TargetType="Button">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Padding" Value="10,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="12" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
CornerRadius="6" SnapsToDevicePixels="True">
<Border x:Name="Bd" CornerRadius="5" SnapsToDevicePixels="True"
BorderThickness="1" BorderBrush="{DynamicResource Ui.Brush.Btn.SecondaryBorder}"
Background="{DynamicResource Ui.Brush.Btn.SecondaryBg}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"
RecognizesAccessKey="True" />
</Border>
</Border>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsEnabled" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.SecondaryHover}" />
</MultiTrigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.SecondaryPressed}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Danger: как secondary, акцент на hover (опасное действие) -->
<Style x:Key="UiButtonDanger" TargetType="Button">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Padding" Value="10,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="12" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
CornerRadius="6" SnapsToDevicePixels="True">
<Border x:Name="Bd" CornerRadius="5" SnapsToDevicePixels="True"
BorderThickness="1" BorderBrush="{DynamicResource Ui.Brush.Btn.SecondaryBorder}"
Background="{DynamicResource Ui.Brush.Btn.SecondaryBg}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"
RecognizesAccessKey="True" />
</Border>
</Border>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsEnabled" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.DangerHover}" />
</MultiTrigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.DangerPressed}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Segoe MDL2 Assets: единый вид иконок (системные глифы Windows) -->
<Style x:Key="UiMdlGlyphButton" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="FontSize" Value="16" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,7,0" />
</Style>
<Style x:Key="UiMdlGlyphOnPrimary" TargetType="TextBlock" BasedOn="{StaticResource UiMdlGlyphButton}">
<Setter Property="Foreground" Value="White" />
</Style>
<Style x:Key="UiMdlGlyphTab" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="FontSize" Value="16" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Caption}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,7,0" />
</Style>
<Style x:Key="UiMdlGlyphTree" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="FontSize" Value="15" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,7,0" />
</Style>
<Style x:Key="UiMdlGlyphEmpty" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="FontSize" Value="24" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Caption}" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<!-- Компактная кнопка только с Segoe MDL2 глифом -->
<Style x:Key="UiButtonIconOnlySecondary" TargetType="Button" BasedOn="{StaticResource UiButtonSecondary}">
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Width" Value="36" />
<Setter Property="MinWidth" Value="36" />
<Setter Property="MaxWidth" Value="36" />
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="MaxHeight" Value="{StaticResource Ui.BaseControlHeight}" />
</Style>
<Style x:Key="UiMdlGlyphIconOnlyMuted" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="FontSize" Value="16" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="Margin" Value="0" />
</Style>
<Style x:Key="UiSplitChevronMuted" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="6,2,0,0" />
</Style>
<!-- Ghost: второстепенные действия -->
<Style x:Key="UiButtonGhost" TargetType="Button">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Padding" Value="10,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="12" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
CornerRadius="6" SnapsToDevicePixels="True">
<Border x:Name="Bd" CornerRadius="5" SnapsToDevicePixels="True"
BorderThickness="1" BorderBrush="{DynamicResource Ui.Brush.Btn.GhostBorder}"
Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"
RecognizesAccessKey="True" />
</Border>
</Border>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsEnabled" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.GhostHover}" />
</MultiTrigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.SecondaryHover}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- TextBox: без внутришаблонного Binding/плейсхолдера (избегаем BAML/триггеров TemplatedParent). Плейсхолдер — в UI отдельным TextBlock. -->
<Style x:Key="UiTextInput" TargetType="TextBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Padding" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="FieldRoot" CornerRadius="0" SnapsToDevicePixels="True" BorderThickness="1" Padding="0"
BorderBrush="{DynamicResource Ui.Brush.InputBorder}" Background="{DynamicResource Ui.Brush.InputBackground}">
<ScrollViewer x:Name="PART_ContentHost" Focusable="false" VerticalContentAlignment="Center" Padding="{StaticResource Ui.InputPadding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="FieldRoot" Property="Background" Value="{DynamicResource Ui.Brush.InputDisabledBg}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.TextDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiPathField" TargetType="TextBox" BasedOn="{StaticResource UiTextInput}">
<Setter Property="IsReadOnly" Value="True" />
</Style>
<Style x:Key="UiPasswordInput" TargetType="PasswordBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="PasswordBox">
<Border x:Name="FieldRoot" CornerRadius="0" SnapsToDevicePixels="True" BorderThickness="1"
BorderBrush="{DynamicResource Ui.Brush.InputBorder}" Background="{DynamicResource Ui.Brush.InputBackground}">
<ScrollViewer x:Name="PART_ContentHost" Focusable="false" VerticalContentAlignment="Center" Padding="{StaticResource Ui.InputPadding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="FieldRoot" Property="Background" Value="{DynamicResource Ui.Brush.InputDisabledBg}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.TextDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiComboItem" TargetType="ComboBoxItem">
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="B" SnapsToDevicePixels="True" Padding="{TemplateBinding Padding}" Background="Transparent" BorderBrush="Transparent" BorderThickness="0">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="B" Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiCombo" TargetType="ComboBox">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="ItemContainerStyle" Value="{StaticResource UiComboItem}" />
<Setter Property="IsEditable" Value="False" />
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Padding" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid SnapsToDevicePixels="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="0" />
<ColumnDefinition Width="22" />
</Grid.ColumnDefinitions>
<Border x:Name="Bd" Grid.ColumnSpan="2" Panel.ZIndex="0" SnapsToDevicePixels="True" BorderBrush="{DynamicResource Ui.Brush.InputBorder}" BorderThickness="1" Background="{DynamicResource Ui.Brush.InputBackground}" />
<ToggleButton x:Name="OpenHit" Grid.ColumnSpan="2" Panel.ZIndex="1" Focusable="False" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" TabIndex="0"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent" SnapsToDevicePixels="True" />
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter x:Name="ContentSite" Grid.Column="0" Panel.ZIndex="2" IsHitTestVisible="False" Margin="8,4,6,4" VerticalAlignment="Center" HorizontalAlignment="Left" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
Content="{TemplateBinding SelectionBoxItem}" ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}" ContentTemplate="{TemplateBinding ItemTemplate}" ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" />
<Path x:Name="Arrow" Grid.Column="1" Panel.ZIndex="2" IsHitTestVisible="False" VerticalAlignment="Center" HorizontalAlignment="Center" SnapsToDevicePixels="True" Fill="{DynamicResource Ui.Brush.Muted}" Data="M0,0 L3.2,2.2 L6.2,0 Z" />
</Grid>
<Popup x:Name="PART_Popup" AllowsTransparency="True" StaysOpen="False" IsOpen="{TemplateBinding IsDropDownOpen}" Focusable="False" Placement="Bottom" VerticalOffset="0" HorizontalOffset="0" PopupAnimation="None" PlacementTarget="{Binding ElementName=Bd}" SnapsToDevicePixels="True">
<Border MinWidth="{Binding ActualWidth, ElementName=Bd, Mode=OneWay}" MaxHeight="320" SnapsToDevicePixels="True" BorderBrush="{DynamicResource Ui.Brush.Border}" BorderThickness="1" Background="{DynamicResource Ui.Brush.SurfaceSubtle}">
<ScrollViewer CanContentScroll="True" MinHeight="1" MinWidth="0" MaxHeight="320" BorderThickness="0" Padding="0">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" KeyboardNavigation.DirectionalNavigation="Contained" />
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.ComboHoverBg}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsDropDownOpen" Value="False" />
<Condition Property="IsKeyboardFocusWithin" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiDatePicker" TargetType="DatePicker">
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Background" Value="{DynamicResource Ui.Brush.InputBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.InputBorder}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="SnapsToDevicePixels" Value="True" />
</Style>
<!-- CheckBox: modern box 18, check + stroke -->
<Style x:Key="UiCheckBox" TargetType="CheckBox">
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal">
<Viewbox Width="18" Height="18" SnapsToDevicePixels="True" VerticalAlignment="Center">
<Grid Width="18" Height="18">
<Border x:Name="Box" CornerRadius="0" BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}" BorderThickness="1" Background="{DynamicResource Ui.Brush.Surface}" />
<Path x:Name="Mark" Data="M 3.2,8.2 L 6.2,11.1 12.1,3.1" Stroke="White" StrokeThickness="1.6" StrokeLineJoin="Round" Visibility="Collapsed" />
</Grid>
</Viewbox>
<ContentPresenter Margin="10,0,0,0" VerticalAlignment="Center" />
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Ui.Brush.Accent}" />
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
<Setter TargetName="Mark" Property="Visibility" Value="Visible" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsChecked" Value="False" />
</MultiTrigger.Conditions>
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Ui.Brush.ControlHover}" />
</MultiTrigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiRadio" TargetType="RadioButton">
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<StackPanel Orientation="Horizontal">
<Viewbox Width="18" Height="18">
<Grid Width="18" Height="18">
<Border x:Name="Outer" CornerRadius="9" BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}" BorderThickness="1" Background="{DynamicResource Ui.Brush.Surface}" />
<Ellipse x:Name="Inner" Width="8" Height="8" Fill="{DynamicResource Ui.Brush.Accent}" Opacity="0" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Viewbox>
<ContentPresenter Margin="10,0,0,0" VerticalAlignment="Center" />
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Inner" Property="Opacity" Value="1" />
<Setter TargetName="Outer" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsChecked" Value="False" />
</MultiTrigger.Conditions>
<Setter TargetName="Outer" Property="Background" Value="{DynamicResource Ui.Brush.ControlHover}" />
</MultiTrigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="RadioButton" BasedOn="{StaticResource UiRadio}" />
<Style TargetType="CheckBox" BasedOn="{StaticResource UiCheckBox}" />
<Style x:Key="UiContextMenu" TargetType="ContextMenu">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.SurfaceSubtle}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="2" />
</Style>
<Style TargetType="ContextMenu" BasedOn="{StaticResource UiContextMenu}" />
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="8,4" />
<Style.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.TextDisabled}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="UiDataGridTextElement" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style x:Key="UiDataGridTextEdit" TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style x:Key="UiDataGrid" TargetType="DataGrid">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Surface}" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="RowBackground" Value="{DynamicResource Ui.Brush.DataRowA}" />
<Setter Property="AlternatingRowBackground" Value="{DynamicResource Ui.Brush.DataRowB}" />
<Setter Property="RowHeight" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="CanUserAddRows" Value="False" />
<Setter Property="AutoGenerateColumns" Value="False" />
<Setter Property="SelectionMode" Value="Single" />
<Setter Property="SelectionUnit" Value="FullRow" />
<Setter Property="RowHeaderWidth" Value="0" />
</Style>
<Style x:Key="UiDataGridColumnHeader" TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataHeader}" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.DataLine}" />
<Setter Property="BorderThickness" Value="0,0,1,1" />
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Style>
<Style x:Key="UiDataGridCell" TargetType="DataGridCell">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border x:Name="Cbd" Padding="{TemplateBinding Padding}" Background="Transparent" SnapsToDevicePixels="True">
<ContentPresenter x:Name="ContentSite" VerticalAlignment="Center" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="Selector.IsSelectionActive" Value="False" />
</MultiTrigger.Conditions>
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</MultiTrigger>
</Style.Triggers>
</Style>
<Style x:Key="UiDataGridRow" TargetType="DataGridRow">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Style.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="False" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowHover}" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="IsMouseOver" Value="False" />
</MultiTrigger.Conditions>
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelectHover}" />
</MultiTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="UiTabItem" TargetType="TabItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border x:Name="B" SnapsToDevicePixels="True" BorderThickness="0" Padding="12,0" MinHeight="32" BorderBrush="Transparent" Background="Transparent" Margin="0,0,1,0">
<Grid MinHeight="32">
<ContentPresenter x:Name="C" ContentSource="Header" VerticalAlignment="Center" TextElement.Foreground="{DynamicResource Ui.Brush.Caption}" />
<Border x:Name="Underline" Height="2" VerticalAlignment="Bottom" Background="Transparent" SnapsToDevicePixels="True" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="C" Property="TextElement.Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter TargetName="Underline" Property="Background" Value="{DynamicResource Ui.Brush.Accent}" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="B" Property="Background" Value="{DynamicResource Ui.Brush.DataHeader}" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="False" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="B" Property="Background" Value="{DynamicResource Ui.Brush.TabHover}" />
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiTabControl" TargetType="TabControl">
<Setter Property="ItemContainerStyle" Value="{StaticResource UiTabItem}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabControl">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{DynamicResource Ui.Brush.TabStrip}" BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}" BorderThickness="0,0,0,1" Padding="8,0,0,0" SnapsToDevicePixels="True">
<TabPanel x:Name="HeaderPanel" IsItemsHost="True" />
</Border>
<Border Grid.Row="1" Background="{DynamicResource Ui.Brush.Surface}" BorderBrush="{DynamicResource Ui.Brush.Border}" BorderThickness="1" Padding="10,8" SnapsToDevicePixels="True">
<ContentPresenter x:Name="PART_SelectedContentHost" ContentSource="SelectedContent" />
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiProgressBar" TargetType="ProgressBar">
<Setter Property="Height" Value="3" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Accent}" />
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Track}" />
</Style>
<Style x:Key="UiSliderRepeat" TargetType="RepeatButton">
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Focusable" Value="False" />
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Height" Value="20" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent" SnapsToDevicePixels="True" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiScrollBarThumb" TargetType="Thumb">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="ThumbBorder"
Background="{DynamicResource Ui.Brush.ScrollBarThumb}"
BorderBrush="{DynamicResource Ui.Brush.Border}"
BorderThickness="1"
CornerRadius="2"
SnapsToDevicePixels="True" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ThumbBorder" Property="Background" Value="{DynamicResource Ui.Brush.ScrollBarThumbHover}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="ThumbBorder" Property="Background" Value="{DynamicResource Ui.Brush.Muted}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiScrollBarButton" TargetType="RepeatButton">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Focusable" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="{DynamicResource Ui.Brush.ScrollBarTrack}" SnapsToDevicePixels="True" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.ScrollBarTrack}" />
<Setter Property="Width" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton x:Name="PART_DecreaseButton"
Command="ScrollBar.LineUpCommand"
Style="{DynamicResource UiScrollBarButton}" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{DynamicResource UiScrollBarThumb}" />
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton x:Name="PART_IncreaseButton"
Command="ScrollBar.LineDownCommand"
Style="{DynamicResource UiScrollBarButton}" />
</Track.IncreaseRepeatButton>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="12" />
<Setter Property="Width" Value="Auto" />
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False" />
<Setter TargetName="PART_DecreaseButton" Property="Command" Value="ScrollBar.LineLeftCommand" />
<Setter TargetName="PART_IncreaseButton" Property="Command" Value="ScrollBar.LineRightCommand" />
</Trigger>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Width" Value="12" />
<Setter Property="Height" Value="Auto" />
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="True" />
<Setter TargetName="PART_DecreaseButton" Property="Command" Value="ScrollBar.LineUpCommand" />
<Setter TargetName="PART_IncreaseButton" Property="Command" Value="ScrollBar.LineDownCommand" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiSlider" TargetType="Slider">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Height" Value="20" />
<Setter Property="IsSnapToTickEnabled" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Slider">
<Grid SnapsToDevicePixels="True">
<Border x:Name="TrackB" Height="3" VerticalAlignment="Center" SnapsToDevicePixels="True" Background="{DynamicResource Ui.Brush.Track}" />
<Track x:Name="PART_Track" VerticalAlignment="Center">
<Track.DecreaseRepeatButton>
<RepeatButton Command="{x:Static controls:Slider.DecreaseLarge}" Style="{StaticResource UiSliderRepeat}" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Width="7" Height="16" SnapsToDevicePixels="True" Focusable="True">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border x:Name="T" Background="{DynamicResource Ui.Brush.SliderThumb}" SnapsToDevicePixels="True" BorderBrush="{DynamicResource Ui.Brush.Border}" BorderThickness="1" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="T" Property="Background" Value="{DynamicResource Ui.Brush.Muted}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="T" Property="Background" Value="{DynamicResource Ui.Brush.Text}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="{x:Static controls:Slider.IncreaseLarge}" Style="{StaticResource UiSliderRepeat}" />
</Track.IncreaseRepeatButton>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiLog" TargetType="TextBox" BasedOn="{x:Null}">
<Setter Property="FontFamily" Value="Cascadia Mono,Consolas" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.LogText}" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="TextWrapping" Value="NoWrap" />
<Setter Property="AcceptsReturn" Value="True" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="MinHeight" Value="100" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Lg" SnapsToDevicePixels="True" CornerRadius="0" BorderBrush="{DynamicResource Ui.Brush.LogBorder}" BorderThickness="1" Background="{DynamicResource Ui.Brush.LogBg}" Padding="6,4">
<ScrollViewer x:Name="PART_ContentHost" Focusable="false" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="UiToolBar" TargetType="ToolBar">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Toolbar}" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="8,0" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style x:Key="UiStatusBar" TargetType="StatusBar">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.StatusBar}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Height" Value="22" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,27 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
<Thickness x:Key="Ui.Space.4">0,0,0,4</Thickness>
<Thickness x:Key="Ui.Space.8">0,0,0,8</Thickness>
<Thickness x:Key="Ui.Space.12">0,0,0,12</Thickness>
<Thickness x:Key="Ui.Space.16">0,0,0,16</Thickness>
<Thickness x:Key="Ui.Space.24">0,0,0,24</Thickness>
<Thickness x:Key="Ui.SectionGap">0,0,0,20</Thickness>
<Thickness x:Key="Ui.PagePadding">20,16,20,24</Thickness>
<Thickness x:Key="Ui.CardPadding">20</Thickness>
<Thickness x:Key="Ui.HStack.8">0,0,8,0</Thickness>
<!-- Кнопки, TextBox, ComboBox форм — одна высота -->
<sys:Double x:Key="Ui.BaseControlHeight">32</sys:Double>
<!-- Внутренние отступы полей ввода (PART_ContentHost / ScrollViewer) -->
<Thickness x:Key="Ui.InputPadding">8,4,8,4</Thickness>
<!-- Сетка форм: колонка подписей (для ColumnDefinition.Width) + числа для Width контролов -->
<GridLength x:Key="Ui.FormLabelColumn">160</GridLength>
<sys:Double x:Key="Ui.FormLabelColumnWidth">160</sys:Double>
<sys:Double x:Key="Ui.InputWidthSmall">200</sys:Double>
<sys:Double x:Key="Ui.InputWidthMedium">360</sys:Double>
<sys:Double x:Key="Ui.InputWidthLarge">480</sys:Double>
<!-- DataGrid: горизонтальные вступы, вертикаль — Center -->
<Thickness x:Key="Ui.DataGridCellPadding">6,0,6,0</Thickness>
<sys:Double x:Key="Ui.Radius.4">4</sys:Double>
<sys:Double x:Key="Ui.Radius.6">6</sys:Double>
</ResourceDictionary>

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,81 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using EmbyToolbox.Models;
namespace EmbyToolbox.ViewModels;
public sealed class AddFilesOptionsViewModel : INotifyPropertyChanged
{
private readonly Action<AddFilesOptions> _onAdd;
private readonly Action _onCancel;
private bool _removeForeignAudioAndSubtitles;
public AddFilesOptionsViewModel(Action<AddFilesOptions> onAdd, Action onCancel)
{
_onAdd = onAdd;
_onCancel = onCancel;
AddCommand = new RelayCommand(ExecuteAdd);
CancelCommand = new RelayCommand(() => _onCancel());
}
/// <summary>Взаимоисключающие опции: два свойства, чтобы не использовать TwoWay на одно поле с конвертером (переполнение стека в WPF).</summary>
public bool OptionKeepAllTracks
{
get => !_removeForeignAudioAndSubtitles;
set
{
if (!value)
{
return;
}
if (!_removeForeignAudioAndSubtitles)
{
return;
}
_removeForeignAudioAndSubtitles = false;
OnPropertyChanged();
OnPropertyChanged(nameof(OptionRemoveForeignTracks));
}
}
public bool OptionRemoveForeignTracks
{
get => _removeForeignAudioAndSubtitles;
set
{
if (!value)
{
return;
}
if (_removeForeignAudioAndSubtitles)
{
return;
}
_removeForeignAudioAndSubtitles = true;
OnPropertyChanged();
OnPropertyChanged(nameof(OptionKeepAllTracks));
}
}
public RelayCommand AddCommand { get; }
public RelayCommand CancelCommand { get; }
private void ExecuteAdd()
{
_onAdd(
new AddFilesOptions
{
RemoveForeignAudioAndSubtitles = _removeForeignAudioAndSubtitles
});
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View File

@ -0,0 +1,128 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
namespace EmbyToolbox.ViewModels;
/// <summary>Компактный индикатор прогресса ffprobe-анализа батча файлов.</summary>
public sealed class AnalysisProgressViewModel : INotifyPropertyChanged
{
private readonly Action _onCancel;
private bool _isPanelVisible;
private string _statusLine = string.Empty;
private double _analysisPercent;
private bool _canCancel = true;
public AnalysisProgressViewModel(Action onCancel)
{
_onCancel = onCancel;
CancelCommand = new RelayCommand(ExecuteCancel, () => _canCancel && _isPanelVisible);
}
public ICommand CancelCommand { get; }
public bool IsPanelVisible
{
get => _isPanelVisible;
private set
{
if (_isPanelVisible == value)
{
return;
}
_isPanelVisible = value;
OnPropertyChanged();
OnPropertyChanged(nameof(PanelVisibility));
if (CancelCommand is RelayCommand rc)
{
rc.RaiseCanExecuteChanged();
}
}
}
public Visibility PanelVisibility => _isPanelVisible ? Visibility.Visible : Visibility.Collapsed;
public string StatusLine
{
get => _statusLine;
private set
{
if (_statusLine == value)
{
return;
}
_statusLine = value;
OnPropertyChanged();
}
}
public double AnalysisPercent
{
get => _analysisPercent;
private set
{
if (Math.Abs(_analysisPercent - value) < 0.01)
{
return;
}
_analysisPercent = value;
OnPropertyChanged();
}
}
public void StartBatch(int total)
{
_canCancel = true;
IsPanelVisible = true;
StatusLine = total > 0 ? $"Анализ файлов: 0 из {total}" : "Анализ файлов";
AnalysisPercent = 0;
if (CancelCommand is RelayCommand rc)
{
rc.RaiseCanExecuteChanged();
}
}
public void OnProgress(EmbyToolbox.Services.QueueAnalysisProgress p)
{
if (p.Total <= 0)
{
return;
}
StatusLine = $"Анализ файлов: {p.Processed} из {p.Total}";
AnalysisPercent = 100.0 * p.Processed / p.Total;
}
public async Task FinalizeAndHideAsync(int errorCount)
{
_canCancel = false;
if (CancelCommand is RelayCommand rc)
{
rc.RaiseCanExecuteChanged();
}
if (errorCount > 0)
{
StatusLine = $"Анализ завершен с ошибками: {errorCount}";
AnalysisPercent = 100;
await Task.Delay(2500).ConfigureAwait(true);
}
IsPanelVisible = false;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void ExecuteCancel() => _onCancel();
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

Some files were not shown because too many files have changed in this diff Show More