commit 6264b487fe4db0d0813a0dad604558a83e0a3daa Author: Emby Toolbox Date: Tue May 12 21:33:47 2026 +0500 Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks). Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d88926f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +.vs/ +_build_temp/ +_buildcheck/ +*.user +*.suo +*.userosscache +*.sln.docstates diff --git a/EmbyToolbox.sln b/EmbyToolbox.sln new file mode 100644 index 0000000..bd0f557 --- /dev/null +++ b/EmbyToolbox.sln @@ -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 diff --git a/EmbyToolbox/App.xaml b/EmbyToolbox/App.xaml new file mode 100644 index 0000000..d09a6b1 --- /dev/null +++ b/EmbyToolbox/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/EmbyToolbox/App.xaml.cs b/EmbyToolbox/App.xaml.cs new file mode 100644 index 0000000..83a43ed --- /dev/null +++ b/EmbyToolbox/App.xaml.cs @@ -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); + } +} + + diff --git a/EmbyToolbox/AssemblyInfo.cs b/EmbyToolbox/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/EmbyToolbox/AssemblyInfo.cs @@ -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) +)] diff --git a/EmbyToolbox/Behaviors/ContextMenuOpenOnButtonClickBehavior.cs b/EmbyToolbox/Behaviors/ContextMenuOpenOnButtonClickBehavior.cs new file mode 100644 index 0000000..118630d --- /dev/null +++ b/EmbyToolbox/Behaviors/ContextMenuOpenOnButtonClickBehavior.cs @@ -0,0 +1,47 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; + +namespace EmbyToolbox.Behaviors; + +/// Всплывает по левому клику вместо ПКМ (split-menu кнопка). +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; + } +} diff --git a/EmbyToolbox/Behaviors/ConversionQueueDropTargetBehavior.cs b/EmbyToolbox/Behaviors/ConversionQueueDropTargetBehavior.cs new file mode 100644 index 0000000..0ecc148 --- /dev/null +++ b/EmbyToolbox/Behaviors/ConversionQueueDropTargetBehavior.cs @@ -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; + } +} diff --git a/EmbyToolbox/Behaviors/DataGridAutoScrollSelectionBehavior.cs b/EmbyToolbox/Behaviors/DataGridAutoScrollSelectionBehavior.cs new file mode 100644 index 0000000..e3c212b --- /dev/null +++ b/EmbyToolbox/Behaviors/DataGridAutoScrollSelectionBehavior.cs @@ -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); +} + diff --git a/EmbyToolbox/Behaviors/DataGridRightClickSelectionBehavior.cs b/EmbyToolbox/Behaviors/DataGridRightClickSelectionBehavior.cs new file mode 100644 index 0000000..0b76446 --- /dev/null +++ b/EmbyToolbox/Behaviors/DataGridRightClickSelectionBehavior.cs @@ -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(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(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; + } +} diff --git a/EmbyToolbox/Behaviors/DataGridRowDoubleClickCommandBehavior.cs b/EmbyToolbox/Behaviors/DataGridRowDoubleClickCommandBehavior.cs new file mode 100644 index 0000000..c5e751f --- /dev/null +++ b/EmbyToolbox/Behaviors/DataGridRowDoubleClickCommandBehavior.cs @@ -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; + +/// Двойной клик по строке DataGrid: вызов ICommand с аргументом . +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; + } +} diff --git a/EmbyToolbox/Behaviors/DataGridSelectionChangedCommandBehavior.cs b/EmbyToolbox/Behaviors/DataGridSelectionChangedCommandBehavior.cs new file mode 100644 index 0000000..f58d66f --- /dev/null +++ b/EmbyToolbox/Behaviors/DataGridSelectionChangedCommandBehavior.cs @@ -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); + } + } +} diff --git a/EmbyToolbox/Behaviors/FileDropBehavior.cs b/EmbyToolbox/Behaviors/FileDropBehavior.cs new file mode 100644 index 0000000..596e3a6 --- /dev/null +++ b/EmbyToolbox/Behaviors/FileDropBehavior.cs @@ -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; + } + } +} diff --git a/EmbyToolbox/Behaviors/ListBoxAutoScrollBehavior.cs b/EmbyToolbox/Behaviors/ListBoxAutoScrollBehavior.cs new file mode 100644 index 0000000..9405e65 --- /dev/null +++ b/EmbyToolbox/Behaviors/ListBoxAutoScrollBehavior.cs @@ -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); + } +} diff --git a/EmbyToolbox/Behaviors/MergeDropTargetBehavior.cs b/EmbyToolbox/Behaviors/MergeDropTargetBehavior.cs new file mode 100644 index 0000000..cc02251 --- /dev/null +++ b/EmbyToolbox/Behaviors/MergeDropTargetBehavior.cs @@ -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; + } +} diff --git a/EmbyToolbox/Behaviors/SeriesRenamerDropTargetBehavior.cs b/EmbyToolbox/Behaviors/SeriesRenamerDropTargetBehavior.cs new file mode 100644 index 0000000..59ad8f9 --- /dev/null +++ b/EmbyToolbox/Behaviors/SeriesRenamerDropTargetBehavior.cs @@ -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; + } +} diff --git a/EmbyToolbox/Behaviors/TextBoxAutoScrollBehavior.cs b/EmbyToolbox/Behaviors/TextBoxAutoScrollBehavior.cs new file mode 100644 index 0000000..cb001b5 --- /dev/null +++ b/EmbyToolbox/Behaviors/TextBoxAutoScrollBehavior.cs @@ -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(); + } + } +} diff --git a/EmbyToolbox/Behaviors/TrackExtractionDropTargetBehavior.cs b/EmbyToolbox/Behaviors/TrackExtractionDropTargetBehavior.cs new file mode 100644 index 0000000..ddf02d8 --- /dev/null +++ b/EmbyToolbox/Behaviors/TrackExtractionDropTargetBehavior.cs @@ -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; + } +} diff --git a/EmbyToolbox/Behaviors/TreeViewScrollSyncBehavior.cs b/EmbyToolbox/Behaviors/TreeViewScrollSyncBehavior.cs new file mode 100644 index 0000000..62b2ce1 --- /dev/null +++ b/EmbyToolbox/Behaviors/TreeViewScrollSyncBehavior.cs @@ -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>> 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>(); + Groups[group] = list; + } + + list.Add(new WeakReference(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(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(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; + } +} diff --git a/EmbyToolbox/Behaviors/VideoInfoDropTargetBehavior.cs b/EmbyToolbox/Behaviors/VideoInfoDropTargetBehavior.cs new file mode 100644 index 0000000..4a86815 --- /dev/null +++ b/EmbyToolbox/Behaviors/VideoInfoDropTargetBehavior.cs @@ -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; + } +} diff --git a/EmbyToolbox/Converters/BooleanNegationConverter.cs b/EmbyToolbox/Converters/BooleanNegationConverter.cs new file mode 100644 index 0000000..e7875b9 --- /dev/null +++ b/EmbyToolbox/Converters/BooleanNegationConverter.cs @@ -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; + } +} diff --git a/EmbyToolbox/Converters/BooleanToVisibilityConverter.cs b/EmbyToolbox/Converters/BooleanToVisibilityConverter.cs new file mode 100644 index 0000000..de6c47c --- /dev/null +++ b/EmbyToolbox/Converters/BooleanToVisibilityConverter.cs @@ -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; +} diff --git a/EmbyToolbox/Converters/TrackActionKindToRussianConverter.cs b/EmbyToolbox/Converters/TrackActionKindToRussianConverter.cs new file mode 100644 index 0000000..156d9b7 --- /dev/null +++ b/EmbyToolbox/Converters/TrackActionKindToRussianConverter.cs @@ -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; +} diff --git a/EmbyToolbox/EmbyToolbox.csproj b/EmbyToolbox/EmbyToolbox.csproj new file mode 100644 index 0000000..cc2e6b0 --- /dev/null +++ b/EmbyToolbox/EmbyToolbox.csproj @@ -0,0 +1,32 @@ + + + WinExe + net9.0-windows10.0.17763.0 + enable + enable + true + PerMonitorV2 + Resources\AppIcon.ico + icons8-emby-96.png + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + True + \ + + + diff --git a/EmbyToolbox/Interop/AppUserModelIdRegistration.cs b/EmbyToolbox/Interop/AppUserModelIdRegistration.cs new file mode 100644 index 0000000..5b78e19 --- /dev/null +++ b/EmbyToolbox/Interop/AppUserModelIdRegistration.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices; + +namespace EmbyToolbox.Interop; + +/// Вызов SetCurrentProcessExplicitAppUserModelID до показа окон/unpackaged toasts Windows. +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; } + + /// true, если получен код 0 (S_OK). + 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; + } + } +} diff --git a/EmbyToolbox/MainWindow.xaml b/EmbyToolbox/MainWindow.xaml new file mode 100644 index 0000000..98f65e8 --- /dev/null +++ b/EmbyToolbox/MainWindow.xaml @@ -0,0 +1,1062 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmbyToolbox/MainWindow.xaml.cs b/EmbyToolbox/MainWindow.xaml.cs new file mode 100644 index 0000000..8acdfce --- /dev/null +++ b/EmbyToolbox/MainWindow.xaml.cs @@ -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(); + } + } +} diff --git a/EmbyToolbox/Models/AddFilesOptions.cs b/EmbyToolbox/Models/AddFilesOptions.cs new file mode 100644 index 0000000..28a5814 --- /dev/null +++ b/EmbyToolbox/Models/AddFilesOptions.cs @@ -0,0 +1,6 @@ +namespace EmbyToolbox.Models; + +public sealed class AddFilesOptions +{ + public bool RemoveForeignAudioAndSubtitles { get; init; } +} diff --git a/EmbyToolbox/Models/ConversionPlan.cs b/EmbyToolbox/Models/ConversionPlan.cs new file mode 100644 index 0000000..15ec9d7 --- /dev/null +++ b/EmbyToolbox/Models/ConversionPlan.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace EmbyToolbox.Models; + +/// План шагов конвертации и краткое отображение. +public sealed class ConversionPlan +{ + public IReadOnlyList StepDescriptions { get; init; } = Array.Empty(); + public IReadOnlyList TrackParts { get; init; } = Array.Empty(); + 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; } + /// MPEG-TS → MKV: первичная попытка copy с genpts; при ошибке timestamp возможен fallback на перекодирование видео. + public bool RequiresTimestampFix { get; init; } +} diff --git a/EmbyToolbox/Models/ConversionPlanAction.cs b/EmbyToolbox/Models/ConversionPlanAction.cs new file mode 100644 index 0000000..c95c2a5 --- /dev/null +++ b/EmbyToolbox/Models/ConversionPlanAction.cs @@ -0,0 +1,20 @@ +namespace EmbyToolbox.Models; + +/// Тип элемента плана (для расширения, отображение в UI — строки в ). +public enum ConversionPlanAction +{ + None, + Skip, + RemuxToMkv, + RemuxToMp4, + ConvertVideo, + ConvertPixelFormat, + Resize, + LimitFps, + ConvertAudio, + RemoveNonRusAudio, + RemoveNonRusSubtitles, + AddExternalAudio, + AddExternalSubtitles, + RemoveDataStreams +} diff --git a/EmbyToolbox/Models/ConversionPlanActionStats.cs b/EmbyToolbox/Models/ConversionPlanActionStats.cs new file mode 100644 index 0000000..bd65a02 --- /dev/null +++ b/EmbyToolbox/Models/ConversionPlanActionStats.cs @@ -0,0 +1,77 @@ +namespace EmbyToolbox.Models; + +/// Сводка количеств операций в плане (источник — + профиль). +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); + } +} diff --git a/EmbyToolbox/Models/ConversionQueueItem.cs b/EmbyToolbox/Models/ConversionQueueItem.cs new file mode 100644 index 0000000..f247af8 --- /dev/null +++ b/EmbyToolbox/Models/ConversionQueueItem.cs @@ -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; + /// True после успешного ffprobe (аудио-поля валидны для отображения). + private bool _ffprobeAnalyzed; + private int _ffprobeAudioCount; + private int? _ffprobeAudioSizeMb; + private bool _ffprobeAudioSizePartial; + private MediaAnalysisResult? _mediaAnalysis; + private IReadOnlyList _sidecars = System.Array.Empty(); + private IReadOnlyList _externalAudioFiles = System.Array.Empty(); + 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(); + } + } + + /// Параметры из ffprobe + списки потоков. + public MediaAnalysisResult? MediaAnalysis + { + get => _mediaAnalysis; + private set + { + if (ReferenceEquals(_mediaAnalysis, value)) + { + return; + } + + _mediaAnalysis = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(TrackSummaryDisplay)); + } + } + + public IReadOnlyList Sidecars + { + get => _sidecars; + private set + { + if (Equals(_sidecars, value)) + { + return; + } + + _sidecars = value; + OnPropertyChanged(); + } + } + + /// Общий корень батча (добавление каталогом или вычисленный LCA файлов при перетаскивании/мультовыборе). Для области snapshot между эпизодами одного добавления. + public string? SnapshotScopeBatchRoot { get; set; } + + /// Разбор внешних аудиофайлов (мультипотоковые контейнеры и т.д.) для пере-seed дорожек. + public IReadOnlyList 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(); + } + + /// Размер файла, МБ (целое, округление). + 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(); + + /// Восстановление медиаданных из снимка (загрузка очереди) без затрагивания . + public void RestorePersistedMediaSnapshot( + MediaAnalysisResult media, + IReadOnlyList sidecars, + IReadOnlyList 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)); + } + } + + /// Есть ли сохранённые краткие данные ffprobe по аудио (для .conv_setup). + public bool HasFfprobeAudioSummary => _ffprobeAnalyzed; + + /// Число аудиопотоков из последнего анализа (0, если не анализировалось). + 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; + + /// Подсказка для «размер аудио» с пометкой * о частичном расчёте. + 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 sidecars, + IReadOnlyList 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 + }; + + /// Сырое значение 0–100 для пайплайна (ffmpeg/копирование); в UI использовать . + public int Progress + { + get => _progress; + set + { + if (_progress == value) + { + return; + } + + _progress = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DisplayProgressPercent)); + } + } + + /// Прогресс для интерфейса: 100% только при статусе «Готово»; иначе не выше 99. + 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(); + } + } + + /// Подробный текст ошибки (stderr ffmpeg/ffprobe и т.д.); для буфера приоритетнее краткого . + 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)); + } + } +} diff --git a/EmbyToolbox/Models/ConversionQueueItemErrorCopy.cs b/EmbyToolbox/Models/ConversionQueueItemErrorCopy.cs new file mode 100644 index 0000000..237e725 --- /dev/null +++ b/EmbyToolbox/Models/ConversionQueueItemErrorCopy.cs @@ -0,0 +1,38 @@ +using System.Collections; + +namespace EmbyToolbox.Models; + +/// Правила контекстного меню «Копировать ошибку» и состава текста буфера обмена. +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); + } + + /// + /// Если есть подробный вывод (ffmpeg stderr, ffprobe stderr и т.д.) — только он; иначе краткий . + /// + public static string GetClipboardText(ConversionQueueItem item) + { + var detail = item.ErrorDetails?.Trim(); + if (!string.IsNullOrEmpty(detail)) + { + return detail; + } + + return item.ErrorMessage?.Trim() ?? string.Empty; + } +} diff --git a/EmbyToolbox/Models/ConversionQueueStatus.cs b/EmbyToolbox/Models/ConversionQueueStatus.cs new file mode 100644 index 0000000..b16ab7a --- /dev/null +++ b/EmbyToolbox/Models/ConversionQueueStatus.cs @@ -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 = "Отмена"; +} diff --git a/EmbyToolbox/Models/ConversionTaskOverride.cs b/EmbyToolbox/Models/ConversionTaskOverride.cs new file mode 100644 index 0000000..896d7f4 --- /dev/null +++ b/EmbyToolbox/Models/ConversionTaskOverride.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; + +namespace EmbyToolbox.Models; + +/// Переопределения пользователя: видео-параметры и дорожки. +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 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 +{ + /// Stream index (ffprobe) для встроенных; отрицательный — внешняя дорожка, см. . + 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; } + + /// Для внешнего аудио: порядковый номер потока внутри файла (0…) для ffmpeg -map input:a:N. + public int ExternalAudioStreamOrdinal { get; set; } + + /// Кодек потока (ffprobe) для отображения / snapshot при внешнем аудио. + public string? ExternalStreamCodec { get; set; } + + /// Сводка для столбца Details у внешних дорожек. + public string? ExternalStreamDetails { get; set; } + + /// Число аудиопотоков в том же внешнем файле (для заголовка по умолчанию / snapshot). + public int SameFileExternalAudioStreamCount { get; set; } = 1; + + /// При внешнем аудио: title из ffprobe-тегов, если был; иначе (заголовок сгенерирован из имени файла). + 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 + }; +} diff --git a/EmbyToolbox/Models/ConversionTrackPlan.cs b/EmbyToolbox/Models/ConversionTrackPlan.cs new file mode 100644 index 0000000..f1cfc84 --- /dev/null +++ b/EmbyToolbox/Models/ConversionTrackPlan.cs @@ -0,0 +1,11 @@ +namespace EmbyToolbox.Models; + +/// Краткое описание плана по одной логической дорожке/шагу. +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; +} diff --git a/EmbyToolbox/Models/ExternalAudioDiscovery.cs b/EmbyToolbox/Models/ExternalAudioDiscovery.cs new file mode 100644 index 0000000..2292b3d --- /dev/null +++ b/EmbyToolbox/Models/ExternalAudioDiscovery.cs @@ -0,0 +1,44 @@ +namespace EmbyToolbox.Models; + +/// Сторонний аудиофайл (контейнер или raw), возможно с несколькими аудиопотоками. +public sealed class ExternalAudioFile +{ + public ExternalAudioFile(string fullPath, IReadOnlyList streams) + { + FullPath = fullPath; + Streams = streams; + } + + public string FullPath { get; } + public IReadOnlyList Streams { get; } +} + +/// Один аудиопоток внутри (индекс для ffmpeg -map input:a:N). +public sealed class ExternalAudioStream +{ + public required string FileFullPath { get; init; } + + /// Порядковый номер аудиопотока внутри файла (0 = первый audio), для -map M:a:N. + 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; } +} + +/// Результат поиска sidecar: объединённые файлы для плана/ffmpeg и разобранное внешнее аудио. +public sealed class SidecarDiscoveryResult +{ + public SidecarDiscoveryResult(IReadOnlyList sidecars, IReadOnlyList externalAudioFiles) + { + Sidecars = sidecars; + ExternalAudioFiles = externalAudioFiles; + } + + public IReadOnlyList Sidecars { get; } + + /// Разбор аудиофайлов (пути совпадают с audio-). + public IReadOnlyList ExternalAudioFiles { get; } +} diff --git a/EmbyToolbox/Models/MediaAnalysisResult.cs b/EmbyToolbox/Models/MediaAnalysisResult.cs new file mode 100644 index 0000000..dbe4377 --- /dev/null +++ b/EmbyToolbox/Models/MediaAnalysisResult.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace EmbyToolbox.Models; + +/// Результат детального ffprobe. +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 VideoStreams { get; init; } = Array.Empty(); + public IReadOnlyList AudioStreams { get; init; } = Array.Empty(); + public IReadOnlyList SubtitleStreams { get; init; } = Array.Empty(); + public IReadOnlyList DataStreams { get; init; } = Array.Empty(); + public IReadOnlyList AllStreams { get; init; } = Array.Empty(); + public long? SourceVideoBitrateBps { get; init; } + + /// Основной видеопоток: самый крупный по площади кадра (не первый подряд в JSON — иначе cover/mjpeg может оказаться «основным»). + public MediaStreamInfo? PrimaryVideo => + VideoStreams + .OrderByDescending(static v => ((long)(v.Width ?? 0)) * (v.Height ?? 0)) + .ThenByDescending(static v => v.IsDefault ? 1 : 0) + .FirstOrDefault(); + + /// format.duration, иначе максимум duration среди streams (ffprobe). + 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; + } +} diff --git a/EmbyToolbox/Models/MediaStreamInfo.cs b/EmbyToolbox/Models/MediaStreamInfo.cs new file mode 100644 index 0000000..c478301 --- /dev/null +++ b/EmbyToolbox/Models/MediaStreamInfo.cs @@ -0,0 +1,41 @@ +namespace EmbyToolbox.Models; + +/// Одна встроенная дорожка из ffprobe (streams[]). +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; } + /// Цветметаданные из ffprobe (video): color_space, color_primaries, color_transfer. + 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; } + /// Длительность дорожки (сек) из ffprobe, если задана. + public double? DurationSeconds { get; init; } + + /// Для потоков-вложений matroska: тег ffprobe tags.filename. + public string? AttachmentDeclaredFileName { get; init; } + + /// Для потоков-вложений: tags.mimetype. + public string? AttachmentDeclaredMimeType { get; init; } +} diff --git a/EmbyToolbox/Models/MergeCompletionKind.cs b/EmbyToolbox/Models/MergeCompletionKind.cs new file mode 100644 index 0000000..a5c4345 --- /dev/null +++ b/EmbyToolbox/Models/MergeCompletionKind.cs @@ -0,0 +1,10 @@ +namespace EmbyToolbox.Models; + +/// Итог последней попытки объединения (для статуса и единого прогресса). +public enum MergeCompletionKind +{ + None, + Success, + Cancelled, + Error +} diff --git a/EmbyToolbox/Models/MergeFileItem.cs b/EmbyToolbox/Models/MergeFileItem.cs new file mode 100644 index 0000000..646e88c --- /dev/null +++ b/EmbyToolbox/Models/MergeFileItem.cs @@ -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(); + } + } + + /// Авто-подпись части при перестановке строк; не затирает имя, если пользователь правил вручную. + 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)); + } +} diff --git a/EmbyToolbox/Models/SidecarFile.cs b/EmbyToolbox/Models/SidecarFile.cs new file mode 100644 index 0000000..50cc2ee --- /dev/null +++ b/EmbyToolbox/Models/SidecarFile.cs @@ -0,0 +1,22 @@ +using System; + +namespace EmbyToolbox.Models; + +/// Внешний sidecar-файл (аудио или субтитры) рядом с видео. +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; + } +} diff --git a/EmbyToolbox/Models/SourceKind.cs b/EmbyToolbox/Models/SourceKind.cs new file mode 100644 index 0000000..11400e7 --- /dev/null +++ b/EmbyToolbox/Models/SourceKind.cs @@ -0,0 +1,7 @@ +namespace EmbyToolbox.Models; + +public enum SourceKind +{ + Embedded, + External +} diff --git a/EmbyToolbox/Models/StreamKind.cs b/EmbyToolbox/Models/StreamKind.cs new file mode 100644 index 0000000..6ef48a0 --- /dev/null +++ b/EmbyToolbox/Models/StreamKind.cs @@ -0,0 +1,10 @@ +namespace EmbyToolbox.Models; + +public enum MediaStreamKind +{ + Video, + Audio, + Subtitle, + Attachment, + Data +} diff --git a/EmbyToolbox/Models/TrackActionKind.cs b/EmbyToolbox/Models/TrackActionKind.cs new file mode 100644 index 0000000..f43f1bf --- /dev/null +++ b/EmbyToolbox/Models/TrackActionKind.cs @@ -0,0 +1,10 @@ +namespace EmbyToolbox.Models; + +/// Действие над дорожкой в плане и в окне настроек. +public enum TrackActionKind +{ + Keep, + Convert, + Remove, + Add +} diff --git a/EmbyToolbox/Models/TrackExtractionQueueItem.cs b/EmbyToolbox/Models/TrackExtractionQueueItem.cs new file mode 100644 index 0000000..2644941 --- /dev/null +++ b/EmbyToolbox/Models/TrackExtractionQueueItem.cs @@ -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)); +} diff --git a/EmbyToolbox/Models/TrackExtractionStatuses.cs b/EmbyToolbox/Models/TrackExtractionStatuses.cs new file mode 100644 index 0000000..75f1565 --- /dev/null +++ b/EmbyToolbox/Models/TrackExtractionStatuses.cs @@ -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, +} diff --git a/EmbyToolbox/Models/TrackSettingsSnapshot.cs b/EmbyToolbox/Models/TrackSettingsSnapshot.cs new file mode 100644 index 0000000..c1f3ef7 --- /dev/null +++ b/EmbyToolbox/Models/TrackSettingsSnapshot.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace EmbyToolbox.Models; + +public sealed class TrackSettingsSnapshot +{ + public string FilePath { get; init; } = string.Empty; + + /// Нормализованный абсолютный каталог исходного видеофайла (родитель имени). + public string ScopeDirectory { get; init; } = string.Empty; + + /// При добавлении каталогом — корень выбранной папки; при перетаскивании — общий предок каталогов батча. + public string? ScopeBatchRoot { get; init; } + + public TrackStructureSignature Signature { get; init; } = new(); + public IReadOnlyList Items { get; init; } = []; +} + +public sealed class TrackStructureSignature +{ + public IReadOnlyList 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; } + + /// True, если Title в UI отличается от типового (ffprobe/filename) — тогда при apply копируем Title. + public bool SnapshotTitleWasUserEdited { get; init; } +} diff --git a/EmbyToolbox/Models/TrackSnapshotMatching.cs b/EmbyToolbox/Models/TrackSnapshotMatching.cs new file mode 100644 index 0000000..29595e8 --- /dev/null +++ b/EmbyToolbox/Models/TrackSnapshotMatching.cs @@ -0,0 +1,40 @@ +namespace EmbyToolbox.Models; + +/// Как сопоставлена дорожка текущего файла со snapshot. +public enum MatchingStrategy +{ + None, + /// Type + Source + язык + порядковый номер внутри этой группы (как в текущем файле). + OrdinalByTypeSourceLanguage +} + +/// Результат сопоставления одной дорожки текущего файла. +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? TrackResults); diff --git a/EmbyToolbox/Resources/AppIcon.ico b/EmbyToolbox/Resources/AppIcon.ico new file mode 100644 index 0000000..d186725 Binary files /dev/null and b/EmbyToolbox/Resources/AppIcon.ico differ diff --git a/EmbyToolbox/Resources/Icons/icons8-emby-48.png b/EmbyToolbox/Resources/Icons/icons8-emby-48.png new file mode 100644 index 0000000..33a4906 Binary files /dev/null and b/EmbyToolbox/Resources/Icons/icons8-emby-48.png differ diff --git a/EmbyToolbox/Resources/Icons/icons8-emby-96.png b/EmbyToolbox/Resources/Icons/icons8-emby-96.png new file mode 100644 index 0000000..eac23aa Binary files /dev/null and b/EmbyToolbox/Resources/Icons/icons8-emby-96.png differ diff --git a/EmbyToolbox/Services/AppSettingsService.cs b/EmbyToolbox/Services/AppSettingsService.cs new file mode 100644 index 0000000..8e65a52 --- /dev/null +++ b/EmbyToolbox/Services/AppSettingsService.cs @@ -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"); + } + + /// Нормализация списка профилей (в т.ч. после загрузки .conv_setup). + public static List NormalizeStoredConversionProfiles( + List? profiles) => + NormalizeProfiles(profiles); + + public AppSettings Load() + { + try + { + if (File.Exists(_settingsFilePath)) + { + var json = File.ReadAllText(_settingsFilePath); + var loaded = JsonSerializer.Deserialize(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 NormalizeProfiles(List? profiles) + { + var defaults = CreateDefaultProfiles(); + var defaultByName = defaults.ToDictionary(p => p.Profile, StringComparer.OrdinalIgnoreCase); + var mergedBuiltIn = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var name in new[] { "Emby", "Web", "Archive" }) + { + mergedBuiltIn[name] = CloneEntry(defaultByName[name]); + } + + var customByName = new Dictionary(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(); + 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 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(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 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; } + + /// Конвертация: применять сохранённый snapshot дорожек с предыдущего настроенного файла. + public bool CopyPreviousTrackSettings { get; set; } + + /// Конвертация: выключать default у всех subtitle-дорожек. + public bool DisableSubtitleDefault { get; set; } + + /// После завершения очереди конвертации воспроизводить системный звук Windows. + public bool NotifyCompletionSoundAfterQueue { get; set; } = true; + + /// После завершения очереди конвертации показывать уведомление Windows. + 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; } = "Нет"; +} diff --git a/EmbyToolbox/Services/BulkTrackSettingsService.cs b/EmbyToolbox/Services/BulkTrackSettingsService.cs new file mode 100644 index 0000000..2886a15 --- /dev/null +++ b/EmbyToolbox/Services/BulkTrackSettingsService.cs @@ -0,0 +1,74 @@ +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +public sealed class BulkTrackSettingsService +{ + private readonly TrackStructureComparer _comparer = new(); + + public BulkTrackSelectionAnalysis Analyze(IReadOnlyList 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 targets, + IReadOnlyList 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 MajorityItems, + IReadOnlyList SkippedItems, + bool HasMajority) +{ + public static BulkTrackSelectionAnalysis Empty { get; } = new(null, [], [], false); +} diff --git a/EmbyToolbox/Services/ChapterBuilderService.cs b/EmbyToolbox/Services/ChapterBuilderService.cs new file mode 100644 index 0000000..f2395ec --- /dev/null +++ b/EmbyToolbox/Services/ChapterBuilderService.cs @@ -0,0 +1,42 @@ +using System.Text; + +namespace EmbyToolbox.Services; + +public sealed class ChapterBuilderService +{ + public string BuildFfmetadata(IReadOnlyList chapterTitles, IReadOnlyList 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); +} diff --git a/EmbyToolbox/Services/ConversionExecutionService.cs b/EmbyToolbox/Services/ConversionExecutionService.cs new file mode 100644 index 0000000..811f5ee --- /dev/null +++ b/EmbyToolbox/Services/ConversionExecutionService.cs @@ -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 _resolveHardwareAcceleration; + private readonly SafeFileReplaceService _replace; + private readonly ExternalFileCleanupService _cleanup; + + public ConversionExecutionService( + LoggingService logging, + FfmpegCommandBuilder builder, + FfmpegService ffmpeg, + FfprobeService ffprobe, + FfmpegEncoderDiscoveryService encoderDiscovery, + Func 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 items, + Func resolveProfile, + string? tempRoot, + string runId, + Func 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 resolveProfile, + string tempRoot, + string runId, + Func 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( + 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( + 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); + + /// Ожидаемый нормализованный pix_fmt для проверки; с «без изменений» типичное yuv420p для AVC/HEVC. + 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 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))); + + /// + /// ffprobe может вернуть yuvj420p (JPEG full-range) там, где в профиле задано yuv420p (limited); + /// по сути тот же 8-bit 4:2:0 планарный — для Emby/совместимости считаем допустимым совпадением. + /// + 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); + } +} + diff --git a/EmbyToolbox/Services/ConversionPlanService.cs b/EmbyToolbox/Services/ConversionPlanService.cs new file mode 100644 index 0000000..d27e197 --- /dev/null +++ b/EmbyToolbox/Services/ConversionPlanService.cs @@ -0,0 +1,848 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Строит план сравнения с профилем: без вызова ffmpeg. +public sealed class ConversionPlanService +{ + /// Встроенная аудиодорожка уже соответствует целевому аудиокодеку профиля (AAC и т.д.). + public static bool EmbeddedAudioMatchesProfile(string? fileCodec, ConversionProfileSettingsEntry profile) => + AudioCodecMatchesAgainstLabel(fileCodec, profile.Audio); + + /// Сравнение кодека файла с подписью цели (как в snapshot), без полного профиля. + 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 sidecars, + ConversionProfileSettingsEntry profile, + ConversionTaskOverride? ovr, + IReadOnlyList? externalAudioForBaseline = null) + { + var steps = new List(); + 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 s, string sub) => s.Any(x => x.Contains(sub, StringComparison.OrdinalIgnoreCase)); + + private static string BuildCountSummary( + ConversionProfileSettingsEntry p, + MediaAnalysisResult m, + IReadOnlyList steps, + ConversionPlanActionStats stats, + ConversionTaskOverride? ovr, + int plannedFontAdds, + bool hasRealActions) + { + var parts = new List(); + 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 steps, + ConversionPlanActionStats stats, + MediaAnalysisResult media, + IReadOnlyList sidecars, + ConversionProfileSettingsEntry profile, + ConversionTaskOverride? ovr, + IReadOnlyList? 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 sidecars, + ConversionProfileSettingsEntry profile, + ConversionTaskOverride? ovr, + IReadOnlyList? 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 BuildTrackParts(ConversionTaskOverride? ovr, string container, MediaAnalysisResult? media) + { + if (ovr is null || ovr.TrackOverrides.Count == 0) + { + return []; + } + + var supportsAttachments = SupportsAttachments(container); + var list = new List(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 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); +} diff --git a/EmbyToolbox/Services/ConversionProfileMapping.cs b/EmbyToolbox/Services/ConversionProfileMapping.cs new file mode 100644 index 0000000..22a878b --- /dev/null +++ b/EmbyToolbox/Services/ConversionProfileMapping.cs @@ -0,0 +1,24 @@ +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +public static class ConversionProfileMapping +{ + /// Запасной профиль, если имя в очереди не найдено в списке. + 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 = "Да" + }; +} diff --git a/EmbyToolbox/Services/ConversionQueueSetupPersistence.cs b/EmbyToolbox/Services/ConversionQueueSetupPersistence.cs new file mode 100644 index 0000000..2babb30 --- /dev/null +++ b/EmbyToolbox/Services/ConversionQueueSetupPersistence.cs @@ -0,0 +1,281 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Сохранение и загрузка очереди конвертации (.conv_setup, JSON). +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(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 Profiles { get; set; } = []; + public List Tasks { get; set; } = []; +} + +public sealed class ConversionFormOptionsSnapshot +{ + public List ContainerOptions { get; set; } = []; + public List VideoCodecOptions { get; set; } = []; + public List PixelFormatOptions { get; set; } = []; + public List ResolutionOptions { get; set; } = []; + public List FpsOptions { get; set; } = []; + public List AudioBitrateKbps { get; set; } = []; + public List 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? Sidecars { get; set; } + public List? 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 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 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 + }; +} diff --git a/EmbyToolbox/Services/ExternalFileCleanupService.cs b/EmbyToolbox/Services/ExternalFileCleanupService.cs new file mode 100644 index 0000000..8f11499 --- /dev/null +++ b/EmbyToolbox/Services/ExternalFileCleanupService.cs @@ -0,0 +1,65 @@ +using System.IO; +using System.Linq; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +public sealed class ExternalFileCleanupService +{ + public IReadOnlyList MoveUsedToUseless(string sourceVideoPath, IReadOnlyList 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(); + 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; + } +} + diff --git a/EmbyToolbox/Services/ExtractCommandBuilder.cs b/EmbyToolbox/Services/ExtractCommandBuilder.cs new file mode 100644 index 0000000..b701b92 --- /dev/null +++ b/EmbyToolbox/Services/ExtractCommandBuilder.cs @@ -0,0 +1,172 @@ +using System.Globalization; +using System.IO; +using System.Text; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Имена выходных файлов и аргументы ffmpeg для извлечения аудио/субтитров/вложений (stream copy). +public sealed class ExtractCommandBuilder +{ + /// + /// Безопасный фрагмент имени без расширения для префикса выходных файлов (Movie из Movie.mkv). + /// + 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)]; + } + + /// Базовое имя файла (без пути): все результаты в общих каталогах, префикс — имя исходника без расширения. + 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 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 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(); +} diff --git a/EmbyToolbox/Services/FfmpegCommandBuilder.cs b/EmbyToolbox/Services/FfmpegCommandBuilder.cs new file mode 100644 index 0000000..0db455e --- /dev/null +++ b/EmbyToolbox/Services/FfmpegCommandBuilder.cs @@ -0,0 +1,1217 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +public sealed record FfmpegCommand( + string Executable, + IReadOnlyList ArgumentList, + IReadOnlyList UsedExternalFiles, + IReadOnlyList DisposableAttachmentDumpPaths, + IReadOnlyList AppliedVideoFilters, + string? VideoEncoderForLog) +{ + /// Только для логов и UI; не использовать для запуска процесса. + public string FullCommand => FormatCommandLineForDisplay(Executable, ArgumentList); + + public static string FormatCommandLineForDisplay(string executable, IReadOnlyList argumentList) + { + var parts = new List(argumentList.Count + 1) { QuoteForDisplay(executable) }; + parts.AddRange(argumentList.Select(QuoteForDisplay)); + return string.Join(' ', parts); + } + + private static string QuoteForDisplay(string arg) + { + if (arg.Length == 0) + { + return "\"\""; + } + + var needsQuote = arg.AsSpan().ContainsAny(" \t\r\n\""); + if (!needsQuote) + { + return arg; + } + + return '"' + arg.Replace("\"", "\\\"", StringComparison.Ordinal) + '"'; + } +} + +public sealed class FfmpegCommandBuilder +{ + private const double VideoFpsEpsilon = 0.01; + + public FfmpegCommand Build( + ConversionQueueItem item, + ConversionProfileSettingsEntry profile, + string outputPath, + VideoEncoderSettings? videoEncoder, + bool requiresVideoTranscode, + bool forceVideoTranscode = false) + { + var effectiveVideoTranscode = requiresVideoTranscode || forceVideoTranscode; + var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe"); + var ovr = item.TaskOverride; + var usedExternal = new List(); + var disposableAttachmentDumps = new List(); + + var supportsAttachmentsOut = SupportsAttachments(ovr.TargetContainer, profile.Container); + var embeddedMuxPlans = CollectEmbeddedAttachmentMuxPlans(item, supportsAttachmentsOut, outputPath, disposableAttachmentDumps); + + var appliedVideoFilters = new List(); + string? encoderForLog = null; + + var args = new List + { + "-hide_banner", + "-y", + "-progress", + "pipe:1", + "-nostats" + }; + + var wantGenPts = NeedsGeneratedPts(item) + || MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName); + if (wantGenPts) + { + args.AddRange(["-fflags", "+genpts"]); + } + + if (embeddedMuxPlans is { Count: > 0 }) + { + foreach (var p in embeddedMuxPlans.OrderBy(x => x.TypeOrdinal)) + { + args.Add("-dump_attachment:t:" + p.TypeOrdinal); + args.Add(p.TempDumpFullPath); + } + } + + args.Add("-i"); + args.Add(item.FullPath); + + var inputMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var nextInputIndex = 1; + foreach (var t in ovr.TrackOverrides.Where(t => + t.Source == SourceKind.External && + t.Action == TrackActionKind.Add && + !string.IsNullOrWhiteSpace(t.ExternalPath) && + t.StreamKind is not MediaStreamKind.Attachment)) + { + var p = t.ExternalPath!; + if (!inputMap.ContainsKey(p)) + { + inputMap[p] = nextInputIndex++; + args.Add("-i"); + args.Add(p); + } + } + + var effectiveContainer = EffectiveContainer(ovr, profile); + var media = item.MediaAnalysis; + + var mapped = new List(); + foreach (var t in ovr.TrackOverrides) + { + if (t.Action == TrackActionKind.Remove) + { + continue; + } + + if (t.Source == SourceKind.Embedded) + { + if (media is not null && + t.StreamKind == MediaStreamKind.Subtitle && + !SubtitleCodecRules.ShouldMapEmbeddedSubtitle(media, t, effectiveContainer)) + { + continue; + } + + if (t.StreamKind == MediaStreamKind.Attachment) + { + continue; + } + + if (t.StreamIndex >= 0) + { + mapped.Add(new MapEntry(t)); + } + } + else if (t.Source == SourceKind.External && t.Action == TrackActionKind.Add && !string.IsNullOrWhiteSpace(t.ExternalPath)) + { + var ext = item.Sidecars.FirstOrDefault(s => string.Equals(s.FullPath, t.ExternalPath, StringComparison.OrdinalIgnoreCase)); + if (ext is not null) + { + usedExternal.Add(ext); + } + + if (t.StreamKind == MediaStreamKind.Attachment) + { + continue; + } + + if (inputMap.TryGetValue(t.ExternalPath!, out var idx)) + { + mapped.Add(new MapEntry(t)); + } + } + } + + string? muxFilterComplex420 = null; + var muxFilterComplexEmbeddedStreamIx = -1; + string muxFilterComplexOutLabel = "mbox_vf"; + + if (mapped.Count == 0) + { + args.Add("-map"); + args.Add("0"); + } + else + { + var mappedVideoOutputs = + mapped.Where(m => m.Track.StreamKind == MediaStreamKind.Video && m.Track.Action != TrackActionKind.Remove).ToList(); + if (mappedVideoOutputs.Count == 1 && + mappedVideoOutputs[0].Track is { StreamKind: MediaStreamKind.Video, Source: SourceKind.Embedded } ve && + ve.StreamIndex >= 0) + { + var shouldMuxFc = ve.Action == TrackActionKind.Convert + || (effectiveVideoTranscode && IsPrimaryVideoTrack(item, ve)); + if (shouldMuxFc) + { + var encMux = videoEncoder ?? CreateCpuFallbackVideoEncoder(item, profile); + var mergedPre = MergeTargets(ovr, profile); + if (EncoderIsH264EightBitRestricted(encMux.Codec) + && TryResolveEffectiveTargetPixelNormalized(mergedPre.PixelFormat, encMux.Codec, out var pnMux) + && string.Equals(pnMux, "yuv420p", StringComparison.OrdinalIgnoreCase)) + { + muxFilterComplexEmbeddedStreamIx = ve.StreamIndex; + var muxChain = BuildVideoFilterChain(item, profile, encMux.Codec).ToList(); + if (!muxChain.Any(c => c.StartsWith("format=", StringComparison.OrdinalIgnoreCase) + || c.Contains("format=yuv420p", StringComparison.OrdinalIgnoreCase))) + { + var muxStreamMeta = media?.AllStreams.FirstOrDefault(x => + x.Index == muxFilterComplexEmbeddedStreamIx && x.Kind == MediaStreamKind.Video); + muxChain.Add( + ProbeSuggestsBt2020Gamut(muxStreamMeta) + ? "colorspace=iall=bt2020:all=bt709:range=tv:format=yuv420p" + : "format=yuv420p"); + } + + var muxGraphBody = string.Join(",", muxChain); + muxFilterComplex420 = $"[0:{muxFilterComplexEmbeddedStreamIx}]{muxGraphBody}[{muxFilterComplexOutLabel}]"; + + appliedVideoFilters.Clear(); + appliedVideoFilters.AddRange(muxChain); + } + } + } + + if (muxFilterComplex420 is not null) + { + args.Add("-filter_complex"); + args.Add(muxFilterComplex420); + } + + foreach (var mm in mapped) + { + args.Add("-map"); + var tr = mm.Track; + if (muxFilterComplexEmbeddedStreamIx >= 0 + && tr.StreamKind == MediaStreamKind.Video + && tr.Source == SourceKind.Embedded + && tr.StreamIndex == muxFilterComplexEmbeddedStreamIx + && (tr.Action == TrackActionKind.Convert + || (effectiveVideoTranscode && IsPrimaryVideoTrack(item, tr)))) + { + args.Add('[' + muxFilterComplexOutLabel + ']'); + } + else if (tr.Source == SourceKind.Embedded && tr.StreamIndex >= 0) + { + args.Add("0:" + tr.StreamIndex); + } + else if (tr.Source == SourceKind.External && !string.IsNullOrWhiteSpace(tr.ExternalPath)) + { + if (inputMap.TryGetValue(tr.ExternalPath!, out var inIx)) + { + if (tr.StreamKind == MediaStreamKind.Audio) + { + args.Add($"{inIx}:a:{tr.ExternalAudioStreamOrdinal}"); + } + else + { + args.Add(inIx + ":0"); + } + } + else + { + args.RemoveAt(args.Count - 1); // input index missing despite mapped list consistency + continue; + } + } + } + } + + var mappedVideoOutputCount = + mapped.Count(m => m.Track.StreamKind == MediaStreamKind.Video && m.Track.Action != TrackActionKind.Remove); + + if (mapped.Count == 0) + { + if (effectiveVideoTranscode) + { + var selectedVideoEncoder = videoEncoder ?? CreateCpuFallbackVideoEncoder(item, profile); + encoderForLog = selectedVideoEncoder.Codec; + args.Add("-c:v"); + args.Add(selectedVideoEncoder.Codec); + AppendVideoTranscodeFiltersThenPixFmt(args, item, profile, appliedVideoFilters, + outVideoStreamIndex: null, selectedVideoEncoder.Codec, + mappedVideoOutputsForFilterBinding: 1, omitVfAndPixFmt: false); + AddExtraEncoderArgs(args, BuildVideoEncoderExtraArguments(item, profile, selectedVideoEncoder)); + AppendVideoBitrateModeArgs(args, item, profile, 0); + } + else + { + args.Add("-c:v"); + args.Add("copy"); + } + + args.Add("-c:a"); + args.Add("copy"); + } + else + { + var vIdx = 0; + var aIdx = 0; + var sIdx = 0; + foreach (var m in mapped) + { + switch (m.Track.StreamKind) + { + case MediaStreamKind.Video: + var shouldConvertVideo = m.Track.Action == TrackActionKind.Convert + || (effectiveVideoTranscode && IsPrimaryVideoTrack(item, m.Track)); + if (shouldConvertVideo) + { + var selectedVideoEncoder = videoEncoder ?? CreateCpuFallbackVideoEncoder(item, profile); + encoderForLog = selectedVideoEncoder.Codec; + args.Add("-c:v:" + vIdx); + args.Add(selectedVideoEncoder.Codec); + var omitVfAndPixFmt = muxFilterComplex420 is not null + && muxFilterComplexEmbeddedStreamIx >= 0 + && shouldConvertVideo + && m.Track.Source == SourceKind.Embedded + && m.Track.StreamIndex == muxFilterComplexEmbeddedStreamIx; + + AppendVideoTranscodeFiltersThenPixFmt(args, item, profile, appliedVideoFilters, vIdx, + selectedVideoEncoder.Codec, mappedVideoOutputCount, + omitVfAndPixFmt: omitVfAndPixFmt); + AddExtraEncoderArgs(args, BuildVideoEncoderExtraArguments(item, profile, selectedVideoEncoder)); + AppendVideoBitrateModeArgs(args, item, profile, vIdx); + } + else + { + args.Add("-c:v:" + vIdx); + args.Add("copy"); + } + + vIdx++; + break; + case MediaStreamKind.Audio: + var transcodeAudio = m.Track.Action == TrackActionKind.Convert + || ShouldTranscodeExternalAddedAudio(m.Track, profile); + if (transcodeAudio) + { + args.Add("-c:a:" + aIdx); + args.Add("aac"); + var br = !string.IsNullOrWhiteSpace(m.Track.AudioBitrateKbps) + ? m.Track.AudioBitrateKbps! + : (string.IsNullOrWhiteSpace(ovr.TargetAudioBitrate) ? profile.Bitrate : ovr.TargetAudioBitrate); + args.Add("-b:a:" + aIdx); + args.Add(ToFfmpegBitrate(br)); + } + else + { + args.Add("-c:a:" + aIdx); + args.Add("copy"); + } + + ApplyLangAndDefault(args, "a", aIdx, m.Track); + aIdx++; + break; + case MediaStreamKind.Subtitle: + { + var subCodec = m.Track.Source == SourceKind.Embedded + ? ResolveEmbeddedCodec(media, m.Track.StreamIndex) + : null; + var wantMp4 = IsMp4Container(ovr.TargetContainer, profile.Container); + var needMp4Text = wantMp4 && + (m.Track.Source == SourceKind.External + || m.Track.Action == TrackActionKind.Convert + || SubtitleCodecRules.Mp4RequiresSubtitleTranscode(subCodec)); + if (wantMp4 && needMp4Text) + { + args.Add("-c:s:" + sIdx); + args.Add("mov_text"); + } + else + { + args.Add("-c:s:" + sIdx); + args.Add("copy"); + } + + ApplyLangAndDefault(args, "s", sIdx, m.Track); + sIdx++; + break; + } + } + } + } + + if (supportsAttachmentsOut) + { + var fontIdx = 0; + foreach (var p in embeddedMuxPlans.OrderBy(x => x.TypeOrdinal)) + { + args.Add("-attach"); + args.Add(p.TempDumpFullPath); + if (!string.IsNullOrWhiteSpace(p.DeclaredFileNameForMetadata)) + { + args.Add("-metadata:s:t:" + fontIdx); + args.Add("filename=" + p.DeclaredFileNameForMetadata.Trim()); + } + + args.Add("-metadata:s:t:" + fontIdx); + args.Add("mimetype=" + p.MimeType); + fontIdx++; + } + + foreach (var t in ovr.TrackOverrides.Where(t => t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Attachment && t.Action == TrackActionKind.Add)) + { + if (string.IsNullOrWhiteSpace(t.ExternalPath)) + { + continue; + } + + var ext = item.Sidecars.FirstOrDefault(s => string.Equals(s.FullPath, t.ExternalPath, StringComparison.OrdinalIgnoreCase)); + if (ext is not null) + { + usedExternal.Add(ext); + } + + var attachmentPath = t.ExternalPath!; + var displayName = Path.GetFileName(attachmentPath); + var extOnly = Path.GetExtension(displayName); + if (string.IsNullOrEmpty(extOnly)) + { + extOnly = ".bin"; + } + + args.Add("-attach"); + args.Add(attachmentPath); + args.Add("-metadata:s:t:" + fontIdx); + args.Add("filename=" + displayName); + args.Add("-metadata:s:t:" + fontIdx); + args.Add("mimetype=" + GuessFontAttachmentMime(extOnly)); + fontIdx++; + } + } + + if (MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName)) + { + args.AddRange(["-avoid_negative_ts", "make_zero", "-muxdelay", "0", "-muxpreload", "0"]); + } + + args.Add(outputPath); + + return new FfmpegCommand( + ffmpegPath, + args, + usedExternal.DistinctBy(x => x.FullPath).ToList(), + disposableAttachmentDumps.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + appliedVideoFilters, + encoderForLog); + } + + /// + /// Цепочка видеофильтров: fps → scale → format (совпадает с логикой плана транскодирования). + /// + public static IReadOnlyList BuildVideoFilterChain( + ConversionQueueItem item, + ConversionProfileSettingsEntry profile, + string? videoEncoderCodec = null) + { + var merged = MergeTargets(item.TaskOverride, profile); + var v = item.MediaAnalysis?.PrimaryVideo; + var list = new List(3); + + var fpsClause = TryBuildFpsFilterClause(v, merged.Fps); + if (!string.IsNullOrEmpty(fpsClause)) + { + list.Add(fpsClause); + } + + var scaleClause = TryBuildScaleFilterClause(v, merged.Resolution); + if (!string.IsNullOrEmpty(scaleClause)) + { + list.Add(scaleClause); + } + + var encoderCodecGuess = videoEncoderCodec ?? string.Empty; + var formatClause = + TryBuildRestrictedH264Yuv420pBridgeClause(v, merged.PixelFormat, encoderCodecGuess) + ?? TryBuildFormatFilterClause(v, merged.PixelFormat, encoderCodecGuess); + if (!string.IsNullOrEmpty(formatClause)) + { + list.Add(formatClause); + } + + return list; + } + + /// Итоговый pix_fmt для фильтра/профиля: явный из UI или типичный для кодера (NVENC/x264). + public static bool TryGetEffectiveVideoOutputPixFmt(string mergedPixelFmtUi, string encoderCodec, + [NotNullWhen(true)] out string? pixNorm) + => TryResolveEffectiveTargetPixelNormalized(mergedPixelFmtUi, encoderCodec, out pixNorm); + + /// Нормализует pix_fmt из ffprobe (обрезка «(tv, …)»). + public static string? NormalizeFfprobePixelFormat(string? ffprobePix) + { + if (string.IsNullOrWhiteSpace(ffprobePix)) + { + return null; + } + + var t = ffprobePix.Trim(); + var open = t.IndexOf('(', StringComparison.Ordinal); + if (open >= 0) + { + t = t[..open].Trim(); + } + + t = ToPixNorm(t); + return string.IsNullOrEmpty(t) ? null : t; + } + + private static void AddExtraEncoderArgs(List args, string? extra) + { + if (string.IsNullOrWhiteSpace(extra)) + { + return; + } + + foreach (var tok in extra.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + args.Add(tok); + } + } + + /// + /// План переупаковки встроенного вложения: общий ffmpeg mux запрещает пакеты AVMEDIA_TYPE_ATTACHMENT + /// (см. libavformat mux.c → Received a packet for an attachment stream → EINVAL). + /// Снимаем вложение в файл (-dump_attachment) и подмешиваем через -attach. + /// + private readonly record struct EmbeddedAttachmentMuxPlan( + int TypeOrdinal, + string TempDumpFullPath, + string? DeclaredFileNameForMetadata, + string MimeType); + + private static List CollectEmbeddedAttachmentMuxPlans( + ConversionQueueItem item, + bool supportsAttachmentsOut, + string outputPath, + List disposableAttachmentDumps) + { + var list = new List(); + if (!supportsAttachmentsOut || item.MediaAnalysis is not { } media) + { + return list; + } + + var handledAttachmentStreams = new HashSet(); + + foreach (var t in item.TaskOverride.TrackOverrides) + { + if (t.Action == TrackActionKind.Remove) + { + continue; + } + + if (t.Source != SourceKind.Embedded || t.StreamKind != MediaStreamKind.Attachment || t.StreamIndex < 0) + { + continue; + } + + if (!handledAttachmentStreams.Add(t.StreamIndex)) + { + continue; + } + + var typeOrd = AttachmentTypeOrdinal(media, t.StreamIndex); + if (typeOrd < 0) + { + continue; + } + + var si = media.AllStreams.FirstOrDefault(s => + s.Index == t.StreamIndex && + s.Kind == MediaStreamKind.Attachment); + + var declaredFnRaw = si?.AttachmentDeclaredFileName?.Trim(); + var declaredFn = string.IsNullOrWhiteSpace(declaredFnRaw) + ? null + : Path.GetFileName(declaredFnRaw); + var extFromDeclared = Path.GetExtension(declaredFn); + var ext = string.IsNullOrEmpty(extFromDeclared) + ? ".bin" + : extFromDeclared; + + var outDir = Path.GetDirectoryName(outputPath); + if (string.IsNullOrWhiteSpace(outDir)) + { + continue; + } + + Directory.CreateDirectory(outDir); + var dumpFileName = $"attachment_{typeOrd}{ext}"; + var dumpPath = Path.Combine(outDir, dumpFileName); + + disposableAttachmentDumps.Add(dumpPath); + + var mime = !string.IsNullOrWhiteSpace(si?.AttachmentDeclaredMimeType) + ? si.AttachmentDeclaredMimeType!.Trim() + : GuessFontAttachmentMime(ext); + + list.Add(new EmbeddedAttachmentMuxPlan(typeOrd, dumpPath, declaredFn, mime)); + } + + return list; + } + + /// Индекс вложения для -dump_attachment:t:N среди дорожек codec_type attachment (порядок по stream index). + private static int AttachmentTypeOrdinal(MediaAnalysisResult media, int attachmentStreamIndex) + { + var ordered = media.AllStreams + .Where(s => s.Kind == MediaStreamKind.Attachment) + .OrderBy(s => s.Index) + .ToList(); + + for (var i = 0; i < ordered.Count; i++) + { + if (ordered[i].Index == attachmentStreamIndex) + { + return i; + } + } + + return -1; + } + + private static string GuessFontAttachmentMime(string ext) + { + if (string.Equals(ext, ".ttf", StringComparison.OrdinalIgnoreCase) || + string.Equals(ext, ".ttc", StringComparison.OrdinalIgnoreCase)) + { + return "application/x-truetype-font"; + } + + if (string.Equals(ext, ".otf", StringComparison.OrdinalIgnoreCase)) + { + return "application/font-sfnt"; + } + + return "application/octet-stream"; + } + + private static void ApplyLangAndDefault(List args, string ffType, int outIndex, TrackOverrideEntry t) + { + if (!string.IsNullOrWhiteSpace(t.Language)) + { + args.Add("-metadata:s:" + ffType + ':' + outIndex); + args.Add("language=" + t.Language!.Trim()); + } + + if (!string.IsNullOrWhiteSpace(t.Title)) + { + args.Add("-metadata:s:" + ffType + ':' + outIndex); + args.Add("title=" + t.Title.Trim()); + } + + if (t.Default is true) + { + args.Add("-disposition:" + ffType + ':' + outIndex); + args.Add("default"); + } + else if (t.Default is false) + { + args.Add("-disposition:" + ffType + ':' + outIndex); + args.Add("0"); + } + } + + private static bool SupportsAttachments(string? targetContainer, string profileContainer) + { + var c = string.IsNullOrWhiteSpace(targetContainer) ? profileContainer : targetContainer!; + return c.Contains("mkv", StringComparison.OrdinalIgnoreCase) || c.Contains("matro", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsMp4Container(string? targetContainer, string profileContainer) + { + var c = string.IsNullOrWhiteSpace(targetContainer) ? profileContainer : targetContainer!; + return c.Contains("mp4", StringComparison.OrdinalIgnoreCase) || c.Contains("mov", StringComparison.OrdinalIgnoreCase); + } + + private static string ToFfmpegBitrate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "256k"; + } + + var v = value.Trim().ToLowerInvariant().Replace(" ", string.Empty, StringComparison.Ordinal); + if (v.EndsWith("kbps", StringComparison.Ordinal)) + { + v = v[..^4] + "k"; + } + else if (v.EndsWith("k", StringComparison.Ordinal)) + { + // already ffmpeg style + } + else if (int.TryParse(v, out _)) + { + v += "k"; + } + + return v; + } + + private readonly record struct MapEntry(TrackOverrideEntry Track); + + public static VideoEncoderSettings CreateCpuFallbackVideoEncoder(ConversionQueueItem item, ConversionProfileSettingsEntry profile) + { + var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo; + var isH265 = targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase) + || targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase); + return isH265 + ? new VideoEncoderSettings("libx265", "-preset medium -crf 23") + : new VideoEncoderSettings("libx264", "-preset medium -crf 23"); + } + + /// Порядок как ожидает NVENC/libav: уже после —c:v— цепочка —vf/—filter:v— затем —pix_fmt— и опции кодера. + private static void AppendVideoTranscodeFiltersThenPixFmt( + List args, + ConversionQueueItem item, + ConversionProfileSettingsEntry profile, + List appliedVideoFiltersAccumulator, + int? outVideoStreamIndex, + string encoderCodec, + int mappedVideoOutputsForFilterBinding, + bool omitVfAndPixFmt = false) + { + if (omitVfAndPixFmt) + { + return; + } + + var merged = MergeTargets(item.TaskOverride, profile); + var chain = BuildVideoFilterChain(item, profile, encoderCodec).ToList(); + + var havePix = TryResolveEffectiveTargetPixelNormalized(merged.PixelFormat, encoderCodec, out var pixNorm); + if (!havePix && EncoderIsH264EightBitRestricted(encoderCodec)) + { + pixNorm = "yuv420p"; + havePix = true; + } + + if (havePix) + { + // Без единого format= в -filter и только с —pix_fmt libav вставляет auto_scale (10→10) и падает на Hi10. + AppendRequiredFormat420ForRestrictedH264IfPix420p(chain, pixNorm!, encoderCodec); + } + + if (chain is { Count: > 0 }) + { + if (appliedVideoFiltersAccumulator.Count == 0) + { + appliedVideoFiltersAccumulator.AddRange(chain); + } + + AppendUnifiedVideoFilterArg(args, chain, outVideoStreamIndex, mappedVideoOutputsForFilterBinding); + } + + if (havePix) + { + if (outVideoStreamIndex is { } idx) + { + args.Add("-pix_fmt:v:" + idx); + args.Add(pixNorm!); + } + else + { + args.Add("-pix_fmt"); + args.Add(pixNorm!); + } + } + } + + /// + /// Любой выход yuv420p через libx264 и аппаратные AVC-энкодеры (nvenc/qsv/amf…) при 10-бит входе требует явного format= в -filter. + /// + private static void AppendRequiredFormat420ForRestrictedH264IfPix420p( + List chain, + string resolvedPixNorm, + string encoderCodec) + { + if (!EncoderIsH264EightBitRestricted(encoderCodec) + || !string.Equals(resolvedPixNorm, "yuv420p", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + foreach (var c in chain) + { + if (c.StartsWith("format=", StringComparison.OrdinalIgnoreCase) + || c.Contains("format=yuv420p", StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + + chain.Add("format=yuv420p"); + } + + private static bool TryResolveConcreteTargetPixelNormalized(string mergedPixelFmtUi, [NotNullWhen(true)] out string? pixNorm) + { + pixNorm = null; + if (!HasConcreteFfmpegPixelTargetString(mergedPixelFmtUi)) + { + return false; + } + + pixNorm = NormalizeFfprobePixelFormat(mergedPixelFmtUi.Trim()); + return !string.IsNullOrWhiteSpace(pixNorm); + } + + private static bool TryResolveEffectiveTargetPixelNormalized(string mergedPixelFmtUi, string encoderCodec, + [NotNullWhen(true)] out string? pixNorm) + { + pixNorm = null; + string? profilePx = + TryResolveConcreteTargetPixelNormalized(mergedPixelFmtUi, out var px) ? px : null; + + // Цель из профиля «yuv420p10le» + h264_nvenc: без явного format= цепочка не даёт даунскейла, + // libav ставит auto_scale (10→10) и падает. Для AVC 8-бит ограничиваем выход до yuv420p. + if (profilePx is not null + && IsTenBitFfPixelFormat(profilePx) + && EncoderIsH264EightBitRestricted(encoderCodec)) + { + pixNorm = "yuv420p"; + return true; + } + + if (profilePx is not null) + { + pixNorm = profilePx; + return true; + } + + return TryInferDefaultPixFmtFromEncoder(encoderCodec, out pixNorm); + } + + private static bool EncoderIsH264EightBitRestricted(string encoderCodec) + { + if (string.IsNullOrWhiteSpace(encoderCodec)) + { + return false; + } + + var lc = encoderCodec.Trim().ToLowerInvariant(); + if (string.Equals(lc, "libx264", StringComparison.Ordinal)) + { + return true; + } + + // FFmpeg: h264_nvenc, h264_qsv, h264_amf, h264_vaapi, h264_mediacodec, ... + return lc.StartsWith("h264_", StringComparison.Ordinal); + } + + private static bool IsTenBitFfPixelFormat(string normalizedPixFmt) + { + if (string.IsNullOrWhiteSpace(normalizedPixFmt)) + { + return false; + } + + var p = normalizedPixFmt.ToLowerInvariant(); + return p.Contains("10le", StringComparison.Ordinal) + || p.Contains("10be", StringComparison.Ordinal) + || p.Contains("p10", StringComparison.Ordinal); + } + + private static bool TryInferDefaultPixFmtFromEncoder(string encoderCodec, + [NotNullWhen(true)] out string? pixNorm) + { + pixNorm = null; + if (string.IsNullOrWhiteSpace(encoderCodec)) + { + return false; + } + + var lc = encoderCodec.Trim().ToLowerInvariant(); + if (lc.Contains("nvenc", StringComparison.Ordinal) + || lc.StartsWith("h264_", StringComparison.Ordinal)) + { + pixNorm = "yuv420p"; + return true; + } + + if (string.Equals(lc, "libx264", StringComparison.Ordinal) + || string.Equals(lc, "libx265", StringComparison.Ordinal)) + { + pixNorm = "yuv420p"; + return true; + } + + return false; + } + + private static bool HasConcreteFfmpegPixelTargetString(string? mergedPixelFmtUi) => + !string.IsNullOrWhiteSpace(mergedPixelFmtUi) + && !IsUiUnchanged(mergedPixelFmtUi) + && !mergedPixelFmtUi.Contains("без", StringComparison.OrdinalIgnoreCase); + + public static string ResolveMergedTargetPixelFormatUi(ConversionTaskOverride ovr, ConversionProfileSettingsEntry profile) + { + var merged = MergeTargets(ovr, profile); + return merged.PixelFormat; + } + + private static void AppendUnifiedVideoFilterArg( + List args, + IReadOnlyList filters, + int? outVideoStreamIndex, + int mappedVideoOutputsForFilterBinding) + { + if (filters is not { Count: > 0 }) + { + return; + } + + var joined = string.Join(",", filters); + + // Один видеовыход: -vf надёжнее связывает граф на некоторых сборках libav, чем -filter:v:0 при смешении с аудио. + var useSimpleVf = mappedVideoOutputsForFilterBinding <= 1 && + (outVideoStreamIndex is null || outVideoStreamIndex.Value == 0); + + if (useSimpleVf) + { + args.Add("-vf"); + args.Add(joined); + } + else if (outVideoStreamIndex is { } i) + { + args.Add("-filter:v:" + i); + args.Add(joined); + } + else + { + args.Add("-vf"); + args.Add(joined); + } + } + + private static ConversionProfileSettingsEntry MergeTargets( + ConversionTaskOverride ovr, + ConversionProfileSettingsEntry 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 void AppendVideoBitrateModeArgs( + List args, + ConversionQueueItem item, + ConversionProfileSettingsEntry profile, + int outVideoStreamIndex) + { + var merged = MergeTargets(item.TaskOverride, profile); + var targetKbps = VideoBitratePolicy.ResolveTargetKbps( + merged.VideoBitrateMode, + merged.VideoBitrateMbps, + item.MediaAnalysis); + if (targetKbps is not { } kbps || kbps <= 0) + { + return; + } + + args.Add("-b:v:" + outVideoStreamIndex); + args.Add(kbps + "k"); + args.Add("-maxrate:v:" + outVideoStreamIndex); + args.Add(kbps + "k"); + args.Add("-bufsize:v:" + outVideoStreamIndex); + args.Add((kbps * 2) + "k"); + } + + private static string BuildVideoEncoderExtraArguments( + ConversionQueueItem item, + ConversionProfileSettingsEntry profile, + VideoEncoderSettings encoder) + { + var merged = MergeTargets(item.TaskOverride, profile); + var targetKbps = VideoBitratePolicy.ResolveTargetKbps( + merged.VideoBitrateMode, + merged.VideoBitrateMbps, + item.MediaAnalysis); + if (targetKbps is null) + { + return encoder.ExtraArguments; + } + + return encoder.Codec.ToLowerInvariant() switch + { + "libx264" or "libx265" => "-preset medium", + "h264_nvenc" or "hevc_nvenc" => "-preset p5", + "h264_qsv" or "hevc_qsv" => string.Empty, + "h264_amf" or "hevc_amf" => "-quality quality", + _ => encoder.ExtraArguments + }; + } + + private static bool IsUiUnchanged(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 bool ProbeSuggestsBt2020Gamut(MediaStreamInfo? v) + { + if (v is null) + { + return false; + } + + static bool Has2020(string? s) => + !string.IsNullOrWhiteSpace(s) && s.Contains("2020", StringComparison.OrdinalIgnoreCase); + + return Has2020(v.ColorSpace) || Has2020(v.ColorPrimaries) || Has2020(v.ColorTransfer); + } + + /// + /// Hi10 yuv420p10le при цели yuv420p + AVC/NVENC: для разметки BT.2020 (частые BDRip) недостаточно format=yuv420p — + /// colorspace задаёт связный даунграйд без auto_scale по цвету. + /// + private static string? TryBuildRestrictedH264Yuv420pBridgeClause( + MediaStreamInfo? v, + string mergedPixelFmtUi, + string encoderCodec) + { + if (!EncoderIsH264EightBitRestricted(encoderCodec)) + { + return null; + } + + if (!TryResolveEffectiveTargetPixelNormalized(mergedPixelFmtUi, encoderCodec, out var targetNorm) + || !string.Equals(targetNorm, "yuv420p", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var srcNorm = NormalizeFfprobePixelFormat(v?.PixelFormat); + if (string.IsNullOrEmpty(srcNorm)) + { + return null; + } + + if (string.Equals(srcNorm, "yuv420p", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (ProbeSuggestsBt2020Gamut(v)) + { + return "colorspace=iall=bt2020:all=bt709:range=tv:format=yuv420p"; + } + + return "format=yuv420p"; + } + + private static string? TryBuildFormatFilterClause(MediaStreamInfo? v, string mergedPixelFmtUi, string encoderCodec) + { + if (v is null || !TryResolveEffectiveTargetPixelNormalized(mergedPixelFmtUi, encoderCodec, out var targetNorm)) + { + return null; + } + + var srcNorm = NormalizeFfprobePixelFormat(v.PixelFormat); + if (string.IsNullOrEmpty(srcNorm)) + { + return null; + } + + if (!string.Equals(srcNorm, targetNorm, StringComparison.OrdinalIgnoreCase)) + { + return "format=" + targetNorm; + } + + return null; + } + + private static string? TryBuildFpsFilterClause(MediaStreamInfo? v, string mergedFpsUi) + { + if (v?.FrameRate is not { } f || f <= 0 || IsUiUnchanged(mergedFpsUi)) + { + return null; + } + + if (TryParseMaxNumericFromUiLabel(mergedFpsUi) is not { } cap || f <= cap + VideoFpsEpsilon) + { + return null; + } + + var capStr = FormatFpsForFilter(cap); + return $"fps={capStr}"; + } + + private static string FormatFpsForFilter(double fps) + { + if (Math.Abs(fps % 1) < VideoFpsEpsilon) + { + return ((int)fps).ToString(CultureInfo.InvariantCulture); + } + + return fps.ToString("0.###", CultureInfo.InvariantCulture); + } + + private static string? TryBuildScaleFilterClause(MediaStreamInfo? v, string mergedResolutionUi) + { + if (v?.Width is not { } iw || v.Height is not { } ih || iw <= 0 || ih <= 0 + || IsUiUnchanged(mergedResolutionUi)) + { + return null; + } + + // «Максимум Np»: ограничиваем более короткую сторону (как в ConversionPlanService). + if (TryParseMaxNumericFromUiLabel(mergedResolutionUi) is { } shortSideCap) + { + var mn = Math.Min(iw, ih); + if (mn <= shortSideCap + VideoFpsEpsilon) + { + return null; + } + + var maxEsc = shortSideCap.ToString(CultureInfo.InvariantCulture); + + return iw >= ih + ? $"scale=-2:min(ih\\,{maxEsc})" + : $"scale=min(iw\\,{maxEsc}):-2"; + } + + return null; + } + + /// Из подписей UI («Максимум 1080p», «Максимум 60» fps) достаём числовой порог. + private static double? TryParseMaxNumericFromUiLabel(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + if (double.TryParse(raw.Replace(',', '.').Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) + { + return d; + } + + var parts = raw.Trim().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 bool IsPrimaryVideoTrack(ConversionQueueItem item, TrackOverrideEntry track) + { + if (track.Source != SourceKind.Embedded || track.StreamKind != MediaStreamKind.Video || track.StreamIndex < 0) + { + return false; + } + + return item.MediaAnalysis?.PrimaryVideo?.Index == track.StreamIndex; + } + + private static bool NeedsGeneratedPts(ConversionQueueItem item) + { + var sourceIsAvi = item.FileName.EndsWith(".avi", StringComparison.OrdinalIgnoreCase) + || item.MediaAnalysis?.ContainerFormat?.Contains("avi", StringComparison.OrdinalIgnoreCase) is true + || item.MediaAnalysis?.FormatName?.Contains("avi", StringComparison.OrdinalIgnoreCase) is true; + if (!sourceIsAvi) + { + return false; + } + + var hasVideoCopy = item.TaskOverride.TrackOverrides.Any(t => + t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Keep); + return hasVideoCopy; + } + + private static string EffectiveContainer(ConversionTaskOverride ovr, ConversionProfileSettingsEntry profile) => + string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer.Trim(); + + private static string? ResolveEmbeddedCodec(MediaAnalysisResult? analysis, int streamIndex) + { + if (analysis is null || streamIndex < 0) + { + return null; + } + + return analysis.AllStreams.FirstOrDefault(s => s.Index == streamIndex)?.CodecName; + } + + private static bool ShouldTranscodeExternalAddedAudio(TrackOverrideEntry track, ConversionProfileSettingsEntry profile) + { + if (track.Source != SourceKind.External + || track.StreamKind != MediaStreamKind.Audio + || track.Action != TrackActionKind.Add) + { + return false; + } + + if (!TargetAudioRequiresAac(profile.Audio)) + { + return false; + } + + return !IsAacCodec(track.ExternalStreamCodec); + } + + private static bool TargetAudioRequiresAac(string? profileAudioLabel) => + !string.IsNullOrWhiteSpace(profileAudioLabel) + && profileAudioLabel.Contains("aac", StringComparison.OrdinalIgnoreCase); + + private static bool IsAacCodec(string? codecName) => + !string.IsNullOrWhiteSpace(codecName) + && codecName.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase); +} diff --git a/EmbyToolbox/Services/FfmpegEncoderDiscoveryService.cs b/EmbyToolbox/Services/FfmpegEncoderDiscoveryService.cs new file mode 100644 index 0000000..48fa23c --- /dev/null +++ b/EmbyToolbox/Services/FfmpegEncoderDiscoveryService.cs @@ -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? _cachedEncoders; + + public HashSet 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 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 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 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 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(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 ParseEncoderNames(string text) + { + var result = new HashSet(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 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); + } +} diff --git a/EmbyToolbox/Services/FfmpegService.cs b/EmbyToolbox/Services/FfmpegService.cs new file mode 100644 index 0000000..be71bf2 --- /dev/null +++ b/EmbyToolbox/Services/FfmpegService.cs @@ -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); + +/// +/// Запускает ffmpeg с -progress pipe:1 -nostats: асинхронно читает stdout (прогресс) и stderr +/// (обязательно, чтобы не заблокировался процесс), не блокируя UI. +/// +public sealed class FfmpegService +{ + private const int MinProgressReportIntervalMs = 300; + private const string StatusRunning = "В работе"; + + public async Task RunAsync( + FfmpegCommand command, + MediaAnalysisResult? media, + IProgress? 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); + } + + /// + /// — только для fallback, если в прогресс-стриме нет времени. + /// + 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? 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% фазы кодирования (снаружи маппится 0–90% очереди) + 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; + } + + /// + /// out_time_ms / out_time_us / out_time. Значения из key=value по документации ffmpeg: *_ms часто = микросекунды; *_us = микросекунды. + /// + 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; + } + + /// Значение out_time_ms: встречается и как мс, и как мкс; выбираем согласованно с . + 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; + } +} diff --git a/EmbyToolbox/Services/FfprobeAudioInfoParser.cs b/EmbyToolbox/Services/FfprobeAudioInfoParser.cs new file mode 100644 index 0000000..026ab45 --- /dev/null +++ b/EmbyToolbox/Services/FfprobeAudioInfoParser.cs @@ -0,0 +1,171 @@ +using System.Globalization; +using System.Text.Json; + +namespace EmbyToolbox.Services; + +/// +/// Извлечение количества аудиодорожек и оценка суммарного размера аудио по JSON ffprobe. +/// +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(); + 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); diff --git a/EmbyToolbox/Services/FfprobeService.cs b/EmbyToolbox/Services/FfprobeService.cs new file mode 100644 index 0000000..5c103fd --- /dev/null +++ b/EmbyToolbox/Services/FfprobeService.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace EmbyToolbox.Services; + +public sealed class FfprobeService +{ + public async Task AnalyzeAsync(string filePath, CancellationToken cancellationToken = default) + => await AnalyzeInternalAsync(filePath, "-v error -show_format -show_streams -show_chapters -print_format json", cancellationToken); + + public async Task 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 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); + } +} + diff --git a/EmbyToolbox/Services/FileDiscoveryService.cs b/EmbyToolbox/Services/FileDiscoveryService.cs new file mode 100644 index 0000000..4a5a221 --- /dev/null +++ b/EmbyToolbox/Services/FileDiscoveryService.cs @@ -0,0 +1,115 @@ +using System.IO; +using System.Linq; + +namespace EmbyToolbox.Services; + +public sealed class FileDiscoveryService +{ + /// Стабильная сортировка списка видеофайлов по полному нормализованному пути (без учёта регистра). + public static StringComparer QueuePathOrderComparer { get; } = StringComparer.OrdinalIgnoreCase; + + /// + /// Собирает пути к поддерживаемым видео: отдельные файлы, из каталогов — рекурсивно, без дублей. + /// + public IReadOnlyList CollectVideoFilesFromFileSystemEntries(IEnumerable? entryPaths, Action? onError = null) + { + if (entryPaths is null) + { + return []; + } + + var set = new HashSet(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); + } + + /// Возвращает новый список путей, отсортированный по (стабильно). + public static IReadOnlyList SortVideoPathsByFullPath(IEnumerable paths) => + paths.OrderBy(p => p, QueuePathOrderComparer).ToList(); + + public IReadOnlyList DiscoverVideoFiles(string rootDirectory, Action? onError = null) + { + if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) + { + return []; + } + + var result = new List(); + var pending = new Stack(); + 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); +} diff --git a/EmbyToolbox/Services/ForcedSubtitleDetector.cs b/EmbyToolbox/Services/ForcedSubtitleDetector.cs new file mode 100644 index 0000000..ac1de31 --- /dev/null +++ b/EmbyToolbox/Services/ForcedSubtitleDetector.cs @@ -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(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> 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(); + } + + try + { + using var doc = JsonDocument.Parse(probe.Json); + if (!doc.RootElement.TryGetProperty("packets", out var packets) || packets.ValueKind != JsonValueKind.Array) + { + return new Dictionary(); + } + + var aggregate = new Dictionary(); + 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(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(); + } + } + + public async Task 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(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); diff --git a/EmbyToolbox/Services/IProfileSettingsProvider.cs b/EmbyToolbox/Services/IProfileSettingsProvider.cs new file mode 100644 index 0000000..a0d36fb --- /dev/null +++ b/EmbyToolbox/Services/IProfileSettingsProvider.cs @@ -0,0 +1,6 @@ +namespace EmbyToolbox.Services; + +public interface IProfileSettingsProvider +{ + ConversionProfileSettingsEntry? GetProfile(string name); +} diff --git a/EmbyToolbox/Services/LogLevel.cs b/EmbyToolbox/Services/LogLevel.cs new file mode 100644 index 0000000..7547247 --- /dev/null +++ b/EmbyToolbox/Services/LogLevel.cs @@ -0,0 +1,9 @@ +namespace EmbyToolbox.Services; + +public enum LogLevel +{ + Debug = 0, + Info = 1, + Warning = 2, + Error = 3 +} diff --git a/EmbyToolbox/Services/LoggingService.cs b/EmbyToolbox/Services/LoggingService.cs new file mode 100644 index 0000000..6b2438c --- /dev/null +++ b/EmbyToolbox/Services/LoggingService.cs @@ -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 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); + } +} diff --git a/EmbyToolbox/Services/MediaAnalysisParser.cs b/EmbyToolbox/Services/MediaAnalysisParser.cs new file mode 100644 index 0000000..160b3fa --- /dev/null +++ b/EmbyToolbox/Services/MediaAnalysisParser.cs @@ -0,0 +1,380 @@ +using System.Globalization; +using System.Linq; +using System.Text.Json; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Разбор JSON ffprobe в . +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(); + 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; + } +} diff --git a/EmbyToolbox/Services/MergeService.cs b/EmbyToolbox/Services/MergeService.cs new file mode 100644 index 0000000..657b6cc --- /dev/null +++ b/EmbyToolbox/Services/MergeService.cs @@ -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 _tempDirectoryProvider; + private readonly LoggingService _logging; + private readonly FfprobeService _ffprobeService; + private readonly ChapterBuilderService _chapterBuilderService; + + public MergeService( + LoggingService logging, + FfprobeService ffprobeService, + ChapterBuilderService chapterBuilderService, + Func? tempDirectoryProvider = null) + { + _logging = logging; + _ffprobeService = ffprobeService; + _chapterBuilderService = chapterBuilderService; + _tempDirectoryProvider = tempDirectoryProvider ?? (() => null); + } + + public async Task MergeAsync( + IReadOnlyList orderedFiles, + string outputPath, + IProgress? 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(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 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 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? 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); + } +} diff --git a/EmbyToolbox/Services/MpegTsTimestampHelpers.cs b/EmbyToolbox/Services/MpegTsTimestampHelpers.cs new file mode 100644 index 0000000..8d01f1d --- /dev/null +++ b/EmbyToolbox/Services/MpegTsTimestampHelpers.cs @@ -0,0 +1,58 @@ +using System.IO; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// MPEG-TS remux: генерация PTS и fallback при ошибках mux. +public static class MpegTsTimestampHelpers +{ + /// Входной файл распознан как MPEG Transport Stream (.ts / m2ts или format_name mpegts). + 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; + } + + /// Сообщения ffmpeg при copy/remux без валидных PTS. + 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 haystack, string needle) + { + return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/EmbyToolbox/Services/NaturalStringComparer.cs b/EmbyToolbox/Services/NaturalStringComparer.cs new file mode 100644 index 0000000..dada501 --- /dev/null +++ b/EmbyToolbox/Services/NaturalStringComparer.cs @@ -0,0 +1,78 @@ +using System.Collections; +using System.Globalization; + +namespace EmbyToolbox.Services; + +public sealed class NaturalStringComparer : IComparer, 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); +} diff --git a/EmbyToolbox/Services/NotificationService.cs b/EmbyToolbox/Services/NotificationService.cs new file mode 100644 index 0000000..9dad4d5 --- /dev/null +++ b/EmbyToolbox/Services/NotificationService.cs @@ -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; + +/// Звуковые и toast-уведомления после обработки очереди конвертации. +public sealed class NotificationService +{ + /// Должен совпадать с SetCurrentProcessExplicitAppUserModelID при старте приложения. + public const string ToastAppUserModelId = "EmbyToolbox.Desktop"; + + private readonly LoggingService _logging; + private readonly Func _soundPref; + private readonly Func _toastPref; + private readonly Dispatcher? _dispatcher; + + public NotificationService( + LoggingService logging, + Func notifyCompletionSoundAfterQueueEnabled, + Func 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"); + } + }); + } + + /// Кнопка «Проверить уведомление»: звук и toast вне зависимости от флагов уведомлений. + 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 = + "" + + "" + + "" + + "" + EscapeXml(title) + "" + + "" + EscapeXml(body) + "" + + "" + + ""; + + 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("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal) + .Replace("'", "'", StringComparison.Ordinal); + } +} diff --git a/EmbyToolbox/Services/ProfileSettingsProvider.cs b/EmbyToolbox/Services/ProfileSettingsProvider.cs new file mode 100644 index 0000000..fdf431e --- /dev/null +++ b/EmbyToolbox/Services/ProfileSettingsProvider.cs @@ -0,0 +1,15 @@ +using System; + +namespace EmbyToolbox.Services; + +public sealed class ProfileSettingsProvider : IProfileSettingsProvider +{ + private readonly Func _resolve; + + public ProfileSettingsProvider(Func resolve) + { + _resolve = resolve; + } + + public ConversionProfileSettingsEntry? GetProfile(string name) => _resolve(name); +} diff --git a/EmbyToolbox/Services/QueueAnalysisService.cs b/EmbyToolbox/Services/QueueAnalysisService.cs new file mode 100644 index 0000000..b1401ea --- /dev/null +++ b/EmbyToolbox/Services/QueueAnalysisService.cs @@ -0,0 +1,325 @@ +using System.Linq; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Ход пакетного анализа очереди (ffprobe) для IProgress и UI. +public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount); + +/// +/// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по , +/// обновление строки через (UI). +/// +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 RunAsync( + IReadOnlyList items, + Func isStillInQueue, + bool autoRemoveForeignTracks, + bool disableSubtitleDefault, + IProgress? progress, + Func 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 isStillInQueue, + IProgress? progress, + Func 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(), Array.Empty()); + } + + 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 isStillInQueue, + Func 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"); + } +} diff --git a/EmbyToolbox/Services/RecentPathService.cs b/EmbyToolbox/Services/RecentPathService.cs new file mode 100644 index 0000000..5afcd6a --- /dev/null +++ b/EmbyToolbox/Services/RecentPathService.cs @@ -0,0 +1,248 @@ +using System.IO; + +namespace EmbyToolbox.Services; + +/// +/// Сценарии для запоминания последних каталогов в диалогах открытия/сохранения. +/// +public enum RecentPathScenario +{ + SeriesRenamer, + ConversionAddFiles, + ConversionAddFolder, + Merge, + VideoInfoOpenFile, + SettingsTempFolder, + SettingsOutputFolder, + TrackExtractDestination +} + +/// +/// Запоминает последние каталоги по сценариям и общий каталог; сохраняет вместе с . +/// +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; + } + + /// + /// Дополнительный существующий каталог перед стандартным «Видео» (для TEMP: текущий выбранный каталог из настроек). + /// + 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 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 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; + } + } +} diff --git a/EmbyToolbox/Services/SafeFileReplaceService.cs b/EmbyToolbox/Services/SafeFileReplaceService.cs new file mode 100644 index 0000000..db0ef3e --- /dev/null +++ b/EmbyToolbox/Services/SafeFileReplaceService.cs @@ -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? 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? 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); + } + } + } +} + diff --git a/EmbyToolbox/Services/SeriesRenamerService.cs b/EmbyToolbox/Services/SeriesRenamerService.cs new file mode 100644 index 0000000..119350c --- /dev/null +++ b/EmbyToolbox/Services/SeriesRenamerService.cs @@ -0,0 +1,468 @@ +using System.Text.RegularExpressions; +using System.IO; + +namespace EmbyToolbox.Services; + +public sealed class SeriesRenamerService +{ + private static readonly HashSet 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 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(); + 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(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 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 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 { 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 ResolveTargets(List ops, bool isDirectory) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sourceSet = ops.Select(o => o.SourcePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + var reserved = new HashSet(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 ResolveSeasonNumbers(List 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 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 Operations { get; } + + public static SeriesRenamePreview Unsupported(string reason) => new(false, reason, null, null, Array.Empty()); + public static SeriesRenamePreview Supported(SeriesNode current, SeriesNode preview, IReadOnlyList operations) => new(true, null, current, preview, operations); +} + +public sealed record SeriesNode(string NodeKey, string Name, string Kind) +{ + public List 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); +} diff --git a/EmbyToolbox/Services/SidecarDiscoveryService.cs b/EmbyToolbox/Services/SidecarDiscoveryService.cs new file mode 100644 index 0000000..80ac5cd --- /dev/null +++ b/EmbyToolbox/Services/SidecarDiscoveryService.cs @@ -0,0 +1,221 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Поиск внешних аудио и субтитров рядом с видеофайлом; для контейнеров с несколькими аудиопотоками — ffprobe. +public sealed class SidecarDiscoveryService +{ + private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase; + + private static readonly HashSet AudioExts = new(IC) + { + ".mka", ".mkv", ".mp4", ".m4a", + ".ac3", ".eac3", ".aac", ".dts", ".flac", ".wav", ".opus", ".ogg", ".mp3", ".wma", ".aiff", ".aif", ".m4b", ".m4r" + }; + + /// Расширения: внутри файла может быть несколько аудиопотоков → полный ffprobe. + private static readonly HashSet MultiStreamAudioProbeExts = new(IC) + { + ".mka", ".mkv", ".mp4", ".m4a" + }; + + private static readonly HashSet SubExts = new(IC) + { + ".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup", ".smi" + }; + + private static readonly HashSet FontExts = new(IC) + { + ".ttf", ".otf", ".ttc", ".otc" + }; + + private readonly LoggingService? _logging; + + public SidecarDiscoveryService(LoggingService? logging = null) => _logging = logging; + + public async Task DiscoverAsync( + string videoPath, + FfprobeService ffprobe, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath)) + { + return new SidecarDiscoveryResult(Array.Empty(), Array.Empty()); + } + + var dir = Path.GetDirectoryName(videoPath); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + { + return new SidecarDiscoveryResult(Array.Empty(), Array.Empty()); + } + + var baseName = Path.GetFileNameWithoutExtension(videoPath); + var full = Path.GetFullPath(videoPath); + var list = new List(); + + 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 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(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> 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(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('.') + }; + } +} diff --git a/EmbyToolbox/Services/SidecarTitleResolver.cs b/EmbyToolbox/Services/SidecarTitleResolver.cs new file mode 100644 index 0000000..5c992da --- /dev/null +++ b/EmbyToolbox/Services/SidecarTitleResolver.cs @@ -0,0 +1,128 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace EmbyToolbox.Services; + +/// Распознаёт человекочитаемый title внешних audio/subtitle sidecar-файлов. +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; + } +} diff --git a/EmbyToolbox/Services/SnapshotScopePaths.cs b/EmbyToolbox/Services/SnapshotScopePaths.cs new file mode 100644 index 0000000..ff3daaf --- /dev/null +++ b/EmbyToolbox/Services/SnapshotScopePaths.cs @@ -0,0 +1,108 @@ +using System.IO; + +namespace EmbyToolbox.Services; + +/// Область применения snapshot: каталог эпизода и опционально общий корень батча. +public static class SnapshotScopePaths +{ + private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase; + + /// Абсолютный путь без завершающего разделителя. + 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); + } + + /// Узкий общий предок каталогов всех указанных видеофайлов (полные пути). + public static string? TryGetLowestCommonAncestorDirectory(IReadOnlyList 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(EnumerateAncestorDirectoriesInclusive(dirA), IC); + foreach (var cand in EnumerateAncestorDirectoriesInclusive(dirB)) + { + if (ancestorsA.Contains(cand)) + { + return cand; + } + } + + return string.Empty; + } + + /// От указанной папки вверх к корню включая сам каталог. + private static IEnumerable 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; + } + } +} diff --git a/EmbyToolbox/Services/SubtitleCodecRules.cs b/EmbyToolbox/Services/SubtitleCodecRules.cs new file mode 100644 index 0000000..c2f5136 --- /dev/null +++ b/EmbyToolbox/Services/SubtitleCodecRules.cs @@ -0,0 +1,138 @@ +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Правила совместимости субтитров с контейнером MKV/MP4 и codec copy в FFmpeg. +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"; + } + + /// Кодеки, которые можно mux copy в Matroska (без teletext / mov_text и пр.). + 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"; + + /// Текстовые дорожки, для MP4 требующие перекодирования в mov_text (не mux copy). + 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)); + + /// Действо по умолчанию для встроенной дорожки субтитров после анализа. + 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; + } + + /// Встроенную субдорожку с этим решением включают в ffmpeg -map только если истина. + 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; + } + + /// Строковая подпись Codec для дорожки (субтитры отображать как текстовые / teletext). + public static string EmbeddedSubtitleCodecLabel(string? ffprobeCodec) => + IsTeletext(ffprobeCodec) ? "dvb_teletext (subtitle)" : ffprobeCodec ?? "?"; +} diff --git a/EmbyToolbox/Services/SupportedVideoFormats.cs b/EmbyToolbox/Services/SupportedVideoFormats.cs new file mode 100644 index 0000000..0920085 --- /dev/null +++ b/EmbyToolbox/Services/SupportedVideoFormats.cs @@ -0,0 +1,33 @@ +using System.IO; +using System.Linq; + +namespace EmbyToolbox.Services; + +/// Единый список поддерживаемых расширений видео для очереди, объединения и анализа. +public static class SupportedVideoFormats +{ + private static readonly HashSet 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); + } + + /// Фильтр для OpenFileDialog: только поддерживаемые видео. + public static string BuildOpenFileDialogFilter() + { + var globs = string.Join(";", Extensions.OrderBy(e => e, StringComparer.Ordinal).Select(e => $"*{e}")); + return $"Видеофайлы|{globs}|Все файлы|*.*"; + } +} diff --git a/EmbyToolbox/Services/TrackExtractOutputPaths.cs b/EmbyToolbox/Services/TrackExtractOutputPaths.cs new file mode 100644 index 0000000..4ab59fa --- /dev/null +++ b/EmbyToolbox/Services/TrackExtractOutputPaths.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.IO; + +namespace EmbyToolbox.Services; + +/// Единый каталог extract\audio|subtitles|attachments и имена без молчаливого перезаписывания. +public static class TrackExtractOutputPaths +{ + /// Абсолютный путь к каталогу …\extract или null. + 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; + } + } + + /// Если в уже есть файл с таким именем — добавляет _1, _2… перед расширением. + 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)); +} diff --git a/EmbyToolbox/Services/TrackExtractionFormats.cs b/EmbyToolbox/Services/TrackExtractionFormats.cs new file mode 100644 index 0000000..28df918 --- /dev/null +++ b/EmbyToolbox/Services/TrackExtractionFormats.cs @@ -0,0 +1,55 @@ +using System.IO; + +namespace EmbyToolbox.Services; + +/// Разрешённые контейнеры для извлечения дорожек. +public static class TrackExtractionFormats +{ + private static readonly HashSet 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 EnumerateMediaFilesRecursive(string rootDirectory) + { + if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) + { + return Array.Empty(); + } + + var bag = new List(); + 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; + } +} diff --git a/EmbyToolbox/Services/TrackExtractionService.cs b/EmbyToolbox/Services/TrackExtractionService.cs new file mode 100644 index 0000000..afb50a1 --- /dev/null +++ b/EmbyToolbox/Services/TrackExtractionService.cs @@ -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 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; + } + + /// Создаёт [destination]\extract\{audio|subtitles|attachments}, возвращает путь к extract. + public string? PrepareExtractLayout(string destinationRootFolder) => + TrackExtractOutputPaths.TryPrepareExtractDirectories(destinationRootFolder); + + /// Успех, stderr ffmpeg. + public async Task<(bool Success, string StdErr)> RunExtractProcessAsync(IReadOnlyList 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"); +} diff --git a/EmbyToolbox/Services/TrackOverrideSeeder.cs b/EmbyToolbox/Services/TrackOverrideSeeder.cs new file mode 100644 index 0000000..734c73d --- /dev/null +++ b/EmbyToolbox/Services/TrackOverrideSeeder.cs @@ -0,0 +1,505 @@ +using System.Globalization; +using System.IO; +using System.Linq; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Заполняет значениями по умолчанию. +public static class TrackOverrideSeeder +{ + public static void EnsureDefaults( + ConversionTaskOverride o, + MediaAnalysisResult? media, + IReadOnlyList side, + ConversionProfileSettingsEntry profile, + bool autoRemoveForeignTracks = false, + IReadOnlyList? 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(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); + } + + /// RUS (один файл) или RUS 1, RUS 2, … (порядок как в ). + public static string BuildExternalSubtitleDisplayTitle(int oneBasedIndex, int totalExternalSubtitles) + { + if (totalExternalSubtitles <= 0 || oneBasedIndex < 1) + { + return "RUS"; + } + + return totalExternalSubtitles == 1 ? "RUS" : $"RUS {oneBasedIndex}"; + } + + /// Каноническое title для внешних субтитров: распознанное имя либо fallback RUS / RUS N. + public static string ExternalSubtitleCanonicalTitle( + IReadOnlyList 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 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 ResolveExternalAudioFiles( + IReadOnlyList side, + IReadOnlyList? 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 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 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 BuildCommonTitleByExternalAudioFile( + IReadOnlyList<(ExternalAudioFile file, ExternalAudioStream st)> flattened, + string videoPath, + SidecarTitleResolver resolver) + { + var result = new Dictionary(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 "русский"; + } +} \ No newline at end of file diff --git a/EmbyToolbox/Services/TrackSettingsSnapshotService.cs b/EmbyToolbox/Services/TrackSettingsSnapshotService.cs new file mode 100644 index 0000000..bedf776 --- /dev/null +++ b/EmbyToolbox/Services/TrackSettingsSnapshotService.cs @@ -0,0 +1,314 @@ +using System.Text; +using System.Text.RegularExpressions; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +/// Сопоставление snapshot с текущим файлом: Type + Source + Language + номер дорожки в группе; видео/аудио/субтитры упорядочены по общему рангу. +public sealed class TrackSettingsSnapshotService +{ + private const string LogModule = "conversion.snapshot"; + + /// Только пробельные символы (включая переносы). + private static readonly Regex WhitespaceCollapse = new(@"\s+", RegexOptions.Compiled); + + /// Символы кавычек и типографских кавычек. + private static readonly Regex QuoteStrip = new(@"[""'`´«»„“”‚‘’]", RegexOptions.Compiled); + + /// Спецсимволы помимо букв/цифр/пробела. + 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; + } + + /// + /// batchScopeRootOptional: корень добавления («добавить каталог», общий предок файлов дропом); пустое — сохранять только каталог файла как область точного попадания. + /// + public void SaveSnapshot(string filePath, IReadOnlyList 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 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(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); + } + + /// Не менее двух дорожек с языком und: сопоставление только по очередности внутри группы Kind+Source+und. + public static bool TracksHaveRiskyMultipleUndTracks(IReadOnlyList 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; + } + + /// Нормализация Title только для доп. сравнения (не ключ сопоставления). + 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 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 BuildSnapshotLookup( + IReadOnlyList snapItems) + { + var dict = new Dictionary(); + 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 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() + }; + + /// Rus/eng/und и т.д. + 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; + } +} diff --git a/EmbyToolbox/Services/TrackStructureComparer.cs b/EmbyToolbox/Services/TrackStructureComparer.cs new file mode 100644 index 0000000..e8d7599 --- /dev/null +++ b/EmbyToolbox/Services/TrackStructureComparer.cs @@ -0,0 +1,56 @@ +using System.Text; +using EmbyToolbox.Models; + +namespace EmbyToolbox.Services; + +public sealed class TrackStructureComparer +{ + public string BuildSignature(IReadOnlyList 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); + } +} diff --git a/EmbyToolbox/Services/VideoBitratePolicy.cs b/EmbyToolbox/Services/VideoBitratePolicy.cs new file mode 100644 index 0000000..4dbc702 --- /dev/null +++ b/EmbyToolbox/Services/VideoBitratePolicy.cs @@ -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 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"; + } +} diff --git a/EmbyToolbox/Services/VideoInfoSummaryService.cs b/EmbyToolbox/Services/VideoInfoSummaryService.cs new file mode 100644 index 0000000..233aa1d --- /dev/null +++ b/EmbyToolbox/Services/VideoInfoSummaryService.cs @@ -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 Sidecars, + IReadOnlyList ExternalAudioFiles); + +public sealed class VideoInfoSummaryService +{ + private readonly SidecarTitleResolver _titleResolver = new(); + + public string BuildSummary(MediaAnalysisResult media, SidecarAnalysisResult sidecars) + { + var lines = new List + { + $"Формат: {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 BuildAudioLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars) + { + var lines = new List(); + var counters = new Dictionary(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 BuildSubtitleLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars) + { + var lines = new List(); + var counters = new Dictionary(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 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"; + } +} diff --git a/EmbyToolbox/Themes/UiColors.xaml b/EmbyToolbox/Themes/UiColors.xaml new file mode 100644 index 0000000..436c4c4 --- /dev/null +++ b/EmbyToolbox/Themes/UiColors.xaml @@ -0,0 +1,211 @@ + + + #F3F3F3 + #FFFFFFFF + #EAEAEA + #D4D4D4 + #E1E1E1 + #1A1A1A + #6B6B6B + #6E6E6E + #8C8C8C + #A8A8A8 + #0078D4 + #006CBD + #005A9E + #A1260D + #E0B0A8 + #FFF4F2 + #FDECE8 + #A1260D + #0B6E2F + + #DDF5DD + #F0F0F0 + #FFFFFFFF + #FAFAFA + #E8E8E8 + #D6E8FC + #B0D0F2 + #E1E1E1 + #F5F5F5 + #D4D4D4 + #1E1E1E + #ECECEC + #EAEAEA + #F0F0F0 + #E0E0E0 + #E5E5E5 + + #E1E1E1 + #C8C8C8 + + #2D7DFF + #1F6FE5 + #1856CC + #F5F5F5 + #CCCCCC + #EAEAEA + #E3E3E3 + #FFE5E5 + #FFDADA + #DDDDDD + #F0F0F0 + #E8E8E8 + #E0B0B0 + #A6A6A6 + #7A7A7A + #F0F0F0 + #2C2C2C + #FFFFFFFF + #D4D4D4 + #FFFFFF + #F0F0F0 + #FAFAFA + #0D2D7CE5 + #1A0B79FF + #D9ECFF + #5A8FD8 + #E8D9FF + #FFE8CC + #FFD9D9 + #ECECEC + #7A4E1D + #1A1A1A + #EAF6EA + #FFF7D6 + #FBE3E6 + #FFFFFF + #E3F0E3 + #FFF2CC + #F7D8DC + #DEEBDE + #FFECC2 + #F2D2D7 + #ECEEF1 + #D6E5D6 + #FFE6B0 + #EEC8CE + #E3E5E9 + #B0B8C1 + #D57A80 + #EAF6EA + #4CAF50 + #388E3C + #FFF4CC + #E0A800 + #BF8F00 + #FDECEC + #D9534F + #D9534F + #777777 + #000000 + #B26A00 + #C62828 + #0078D7 + #FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmbyToolbox/Themes/UiComponentStyles.xaml b/EmbyToolbox/Themes/UiComponentStyles.xaml new file mode 100644 index 0000000..a402e44 --- /dev/null +++ b/EmbyToolbox/Themes/UiComponentStyles.xaml @@ -0,0 +1,942 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmbyToolbox/Themes/UiLayout.xaml b/EmbyToolbox/Themes/UiLayout.xaml new file mode 100644 index 0000000..2d8ea0e --- /dev/null +++ b/EmbyToolbox/Themes/UiLayout.xaml @@ -0,0 +1,27 @@ + + 0,0,0,4 + 0,0,0,8 + 0,0,0,12 + 0,0,0,16 + 0,0,0,24 + 0,0,0,20 + 20,16,20,24 + 20 + 0,0,8,0 + + 32 + + 8,4,8,4 + + 160 + 160 + 200 + 360 + 480 + + 6,0,6,0 + 4 + 6 + diff --git a/EmbyToolbox/Tools/ffmpeg.exe b/EmbyToolbox/Tools/ffmpeg.exe new file mode 100644 index 0000000..2407f6f Binary files /dev/null and b/EmbyToolbox/Tools/ffmpeg.exe differ diff --git a/EmbyToolbox/Tools/ffprobe.exe b/EmbyToolbox/Tools/ffprobe.exe new file mode 100644 index 0000000..02e662d Binary files /dev/null and b/EmbyToolbox/Tools/ffprobe.exe differ diff --git a/EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs b/EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs new file mode 100644 index 0000000..2a084fc --- /dev/null +++ b/EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs @@ -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 _onAdd; + private readonly Action _onCancel; + + private bool _removeForeignAudioAndSubtitles; + + public AddFilesOptionsViewModel(Action onAdd, Action onCancel) + { + _onAdd = onAdd; + _onCancel = onCancel; + AddCommand = new RelayCommand(ExecuteAdd); + CancelCommand = new RelayCommand(() => _onCancel()); + } + + /// Взаимоисключающие опции: два свойства, чтобы не использовать TwoWay на одно поле с конвертером (переполнение стека в WPF). + 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)); +} diff --git a/EmbyToolbox/ViewModels/AnalysisProgressViewModel.cs b/EmbyToolbox/ViewModels/AnalysisProgressViewModel.cs new file mode 100644 index 0000000..eaf0b26 --- /dev/null +++ b/EmbyToolbox/ViewModels/AnalysisProgressViewModel.cs @@ -0,0 +1,128 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Input; + +namespace EmbyToolbox.ViewModels; + +/// Компактный индикатор прогресса ffprobe-анализа батча файлов. +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)); + } +} diff --git a/EmbyToolbox/ViewModels/BulkFileConversionSettingsViewModel.cs b/EmbyToolbox/ViewModels/BulkFileConversionSettingsViewModel.cs new file mode 100644 index 0000000..8fe8c4f --- /dev/null +++ b/EmbyToolbox/ViewModels/BulkFileConversionSettingsViewModel.cs @@ -0,0 +1,199 @@ +using System.Collections; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using EmbyToolbox.Models; +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +public sealed class BulkFileConversionSettingsViewModel : INotifyPropertyChanged, ITrackPlanPreviewHost +{ + private readonly ConversionQueueItem _representative; + private readonly ConversionTaskOverride _draft; + private readonly Action> _onApply; + private readonly Action _onClose; + + public BulkFileConversionSettingsViewModel( + ConversionQueueItem representative, + IReadOnlyList targets, + ConversionFormOptions formOptions, + Action> onApply, + Action onClose) + { + _representative = representative; + _draft = representative.TaskOverride.Clone(); + _onApply = onApply; + _onClose = onClose; + FormOptions = formOptions; + + TargetRowNumbersText = string.Join(", ", targets.OrderBy(i => i.OrderNumber).Select(i => i.OrderNumber)); + FilesCount = targets.Count; + + BuildRows(); + ValidateDefaultConflicts(); + + SaveCommand = new RelayCommand(ExecuteSave); + CancelCommand = new RelayCommand(() => _onClose()); + OnSelectionChangedCommand = new RelayCommand(OnSelectionChanged); + SetSelectedTracksRemoveCommand = new RelayCommand(SetSelectedTracksRemove, CanSetSelectedTracksRemove); + } + + public ConversionFormOptions FormOptions { get; } + public ObservableCollection TrackRows { get; } = new(); + public ObservableCollection SelectedTracks { get; } = new(); + public RelayCommand SaveCommand { get; } + public RelayCommand CancelCommand { get; } + public RelayCommand OnSelectionChangedCommand { get; } + public RelayCommand SetSelectedTracksRemoveCommand { get; } + public int FilesCount { get; } + public string TargetRowNumbersText { get; } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void RecalculatePlanPreview() + { + // No per-file plan preview in bulk dialog. + } + + public void OnTrackDefaultEnabled(TrackSettingsRowViewModel row) + { + if (row.DataModel.StreamKind is not (MediaStreamKind.Audio or MediaStreamKind.Subtitle)) + { + return; + } + + foreach (var r in TrackRows) + { + if (ReferenceEquals(r, row)) + { + continue; + } + + if (r.DataModel.StreamKind == row.DataModel.StreamKind) + { + r.Default = false; + } + } + } + + public void ValidateDefaultConflicts() + { + var audioDefaults = TrackRows + .Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio && r.Action != TrackActionKind.Remove && r.Default is true) + .ToList(); + var subDefaults = TrackRows + .Where(r => r.DataModel.StreamKind == MediaStreamKind.Subtitle && r.Action != TrackActionKind.Remove && r.Default is true) + .ToList(); + + foreach (var r in TrackRows) + { + r.HasDefaultConflict = false; + } + + if (audioDefaults.Count > 1) + { + foreach (var r in audioDefaults) + { + r.HasDefaultConflict = true; + } + } + + if (subDefaults.Count > 1) + { + foreach (var r in subDefaults) + { + r.HasDefaultConflict = true; + } + } + } + + private void OnSelectionChanged(object? parameter) + { + SelectedTracks.Clear(); + if (parameter is IList list) + { + foreach (var r in list.OfType()) + { + SelectedTracks.Add(r); + } + } + + SetSelectedTracksRemoveCommand.RaiseCanExecuteChanged(); + } + + private bool CanSetSelectedTracksRemove(object? parameter) + { + if (parameter is not IList list || list.Count == 0) + { + return false; + } + + return list.OfType().Any(r => r.ValidActions.Contains(TrackActionKind.Remove)); + } + + private void SetSelectedTracksRemove(object? parameter) + { + if (parameter is not IList list || list.Count == 0) + { + return; + } + + var rows = list.OfType().ToList(); + var changed = false; + foreach (var row in rows) + { + if (!row.ValidActions.Contains(TrackActionKind.Remove)) + { + continue; + } + + row.Action = TrackActionKind.Remove; + changed = true; + } + + if (changed) + { + ValidateDefaultConflicts(); + SetSelectedTracksRemoveCommand.RaiseCanExecuteChanged(); + } + } + + private void ExecuteSave() + { + ValidateDefaultConflicts(); + var hasAudioConflict = TrackRows.Any(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Audio); + var hasSubConflict = TrackRows.Any(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Subtitle); + if (hasAudioConflict || hasSubConflict) + { + MessageBox.Show( + "Ошибка: для Audio и Subtitle может быть только одна дорожка Default (исключая Remove).", + "Валидация", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + _onApply(_draft.TrackOverrides.Select(t => t.Clone()).ToList()); + _onClose(); + } + + private void BuildRows() + { + TrackRows.Clear(); + var media = _representative.MediaAnalysis; + var idx = 1; + foreach (var t in _draft.TrackOverrides) + { + MediaStreamInfo? em = t.Source == SourceKind.Embedded && media is not null + ? media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex) + : null; + TrackRows.Add(new TrackSettingsRowViewModel(this, t, idx, em, _draft.TargetContainer)); + idx++; + } + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/EmbyToolbox/ViewModels/ConversionFormOptions.cs b/EmbyToolbox/ViewModels/ConversionFormOptions.cs new file mode 100644 index 0000000..fcbed94 --- /dev/null +++ b/EmbyToolbox/ViewModels/ConversionFormOptions.cs @@ -0,0 +1,50 @@ +using System.Linq; +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +/// Списки для ComboBox в окне настроек (синхронизированы с MainWindowViewModel). +public sealed class ConversionFormOptions +{ + public List ContainerOptions { get; } = ["MKV", "MP4", "MOV", "WEBM"]; + public List VideoCodecOptions { get; } = ["H.264", "H.265", "AV1", "Copy"]; + public List PixelFormatOptions { get; } = ["yuv420p", "yuv420p10le", "yuv422p", "yuv444p"]; + public List ResolutionOptions { get; } = ["Без изменений", "Максимум 2160p", "Максимум 1440p", "Максимум 1080p", "Максимум 720p"]; + public List FpsOptions { get; } = ["Без изменений", "Максимум 60", "Максимум 30", "Максимум 25", "Максимум 24"]; + public List AudioBitrateKbps { get; } = ["128 kbps", "192 kbps", "256 kbps", "320 kbps"]; + public List VideoBitrateModeOptions { get; } = VideoBitratePolicy.UiOptions.ToList(); + + internal void RestoreListsFromSerialized(IReadOnlyList? containers, + IReadOnlyList? video, + IReadOnlyList? pixel, + IReadOnlyList? resolution, + IReadOnlyList? fps, + IReadOnlyList? audioBitrate, + IReadOnlyList? videoBitrateMode) + { + ReplaceList(ContainerOptions, containers); + ReplaceList(VideoCodecOptions, video); + ReplaceList(PixelFormatOptions, pixel); + ReplaceList(ResolutionOptions, resolution); + ReplaceList(FpsOptions, fps); + ReplaceList(AudioBitrateKbps, audioBitrate); + ReplaceList(VideoBitrateModeOptions, videoBitrateMode); + } + + private static void ReplaceList(List target, IReadOnlyList? source) + { + if (source is null || source.Count == 0) + { + return; + } + + target.Clear(); + foreach (var s in source) + { + if (!string.IsNullOrWhiteSpace(s)) + { + target.Add(s.Trim()); + } + } + } +} diff --git a/EmbyToolbox/ViewModels/ConversionProfileNames.cs b/EmbyToolbox/ViewModels/ConversionProfileNames.cs new file mode 100644 index 0000000..5f7202c --- /dev/null +++ b/EmbyToolbox/ViewModels/ConversionProfileNames.cs @@ -0,0 +1,24 @@ +namespace EmbyToolbox.ViewModels; + +public static class ConversionProfileNames +{ + public static readonly string[] BuiltInOrder = ["Emby", "Web", "Archive"]; + + public static bool IsBuiltIn(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + foreach (var n in BuiltInOrder) + { + if (name.Equals(n, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/EmbyToolbox/ViewModels/ConversionProfilePresetRow.cs b/EmbyToolbox/ViewModels/ConversionProfilePresetRow.cs new file mode 100644 index 0000000..8904be6 --- /dev/null +++ b/EmbyToolbox/ViewModels/ConversionProfilePresetRow.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +public sealed class ConversionProfilePresetRow : INotifyPropertyChanged +{ + private string _profile = string.Empty; + + public bool IsBuiltIn { get; set; } + + public string Profile + { + get => _profile; + set + { + if (_profile == value) + { + return; + } + + if (!IsBuiltIn && ConversionProfileNames.IsBuiltIn(value)) + { + return; + } + + _profile = value; + OnPropertyChanged(); + } + } + + public string Container { get; set; } = string.Empty; + public string Video { get; set; } = string.Empty; + public string PixelFormat { get; set; } = string.Empty; + public string Resolution { get; set; } = string.Empty; + public string Fps { get; set; } = string.Empty; + public string Audio { get; set; } = string.Empty; + public string Bitrate { get; set; } = string.Empty; + public string VideoBitrateMode { get; set; } = VideoBitratePolicy.Auto; + public double? VideoBitrateMbps { get; set; } + public string Subtitles { get; set; } = string.Empty; + public string ExternalTracks { get; set; } = string.Empty; + public string ExternalSubtitles { get; set; } = string.Empty; + public string Fonts { get; set; } = string.Empty; + + public ConversionProfileSettingsEntry ToSettingsEntry() => + new() + { + Profile = Profile, + Container = Container, + Video = Video, + PixelFormat = PixelFormat, + Resolution = Resolution, + Fps = Fps, + Audio = Audio, + Bitrate = Bitrate, + VideoBitrateMode = VideoBitrateMode, + VideoBitrateMbps = VideoBitrateMbps, + Subtitles = Subtitles, + ExternalTracks = ExternalTracks, + ExternalSubtitles = ExternalSubtitles, + Fonts = Fonts + }; + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/EmbyToolbox/ViewModels/ConversionViewModel.cs b/EmbyToolbox/ViewModels/ConversionViewModel.cs new file mode 100644 index 0000000..34b0252 --- /dev/null +++ b/EmbyToolbox/ViewModels/ConversionViewModel.cs @@ -0,0 +1,1997 @@ +using System.Collections; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Threading; +using EmbyToolbox.Models; +using EmbyToolbox.Services; +using EmbyToolbox.Views; +using Microsoft.Win32; + +namespace EmbyToolbox.ViewModels; + +public sealed class ConversionViewModel : INotifyPropertyChanged +{ + private readonly LoggingService _logging; + private readonly FileDiscoveryService _discoveryService; + private readonly QueueAnalysisService _queueAnalysis; + private readonly ConversionPlanService _planService; + private readonly IProfileSettingsProvider _profile; + private readonly TrackSettingsSnapshotService _trackSnapshotService; + private readonly ConversionExecutionService _execution; + private readonly Func _tempDirectoryProvider; + private readonly RecentPathService _recentPaths; + private readonly NotificationService _notifications; + private readonly Func> _profilesSnapshotForSetup; + private readonly Func> _presetRowsForSetup; + private readonly Action>? _applyProfilesFromSetupDocument; + private readonly BulkTrackSettingsService _bulkTrackSettingsService = new(); + private readonly SemaphoreSlim _batchGate = new(1, 1); + private readonly HashSet _queuedPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly List _selectedQueueItems = []; + private CancellationTokenSource? _analysisCts; + private CancellationTokenSource? _execCts; + + private string _defaultQueueProfile = "Emby"; + private bool _copyPreviousTrackSettings; + private bool _disableSubtitleDefault; + private ConversionProfilePresetRow? _selectedDefaultProfile; + private bool _isQueueDropHighlight; + private bool _isExecutionRunning; + private ConversionQueueItem? _selectedQueueItem; + private int _overallProgressPercent; + private int _completedCount; + private int _totalCount; + private int _overallQueueTotal; + private int _overallQueueDoneCount; + private int _overallQueueErrorCount; + private string? _currentRunId; + private HashSet _currentRunItems = new(); + private string _executionPhaseCaption = string.Empty; + private bool _copyQueueItemErrorMenuVisible; + private string _toastMessage = string.Empty; + private bool _isToastVisible; + private ToastKind _toastKind; + private DispatcherTimer? _toastHideTimer; + + public ObservableCollection QueueTasks { get; } = new(); + public ICollectionView QueueTasksView { get; } + + public AnalysisProgressViewModel AnalysisProgress { get; } + + public ConversionFormOptions FormOptions { get; } = new(); + + public ConversionViewModel( + LoggingService logging, + FileDiscoveryService discoveryService, + QueueAnalysisService queueAnalysis, + ConversionPlanService planService, + IProfileSettingsProvider profile, + TrackSettingsSnapshotService trackSnapshotService, + ConversionExecutionService execution, + Func tempDirectoryProvider, + RecentPathService recentPaths, + NotificationService notifications, + Func> profilesSnapshotForSetup, + Func> presetRowsForSetup, + Action>? applyProfilesFromSetupDocument) + { + _logging = logging; + _discoveryService = discoveryService; + _queueAnalysis = queueAnalysis; + _planService = planService; + _profile = profile; + _trackSnapshotService = trackSnapshotService; + _execution = execution; + _tempDirectoryProvider = tempDirectoryProvider; + _recentPaths = recentPaths; + _notifications = notifications; + _profilesSnapshotForSetup = profilesSnapshotForSetup; + _presetRowsForSetup = presetRowsForSetup; + _applyProfilesFromSetupDocument = applyProfilesFromSetupDocument; + AnalysisProgress = new AnalysisProgressViewModel(() => _analysisCts?.Cancel()); + QueueTasks.CollectionChanged += OnQueueCollectionChanged; + QueueTasksView = CollectionViewSource.GetDefaultView(QueueTasks); + + AddFilesCommand = new RelayCommand(ExecuteAddFiles, () => !IsExecutionRunning); + AddFolderCommand = new RelayCommand(ExecuteAddFolder, () => !IsExecutionRunning); + RemoveSelectedFromQueueCommand = new RelayCommand(RemoveSelectedFromQueue, _ => !IsExecutionRunning); + RemoveSelectedQueueItemsCommand = new RelayCommand(RemoveSelectedQueueItems, CanRemoveSelectedQueueItems); + ShowInFolderCommand = new RelayCommand(ExecuteShowInFolder, CanShowInFolder); + PlayFileCommand = new RelayCommand(ExecutePlayFile, CanPlayFile); + ClearQueueCommand = new RelayCommand(ExecuteClearQueue, () => QueueTasks.Count > 0 && !IsExecutionRunning); + ClearCompletedFromQueueCommand = new RelayCommand(ExecuteClearCompletedFromQueue, CanClearCompletedFromQueue); + OpenFileConversionSettingsCommand = new RelayCommand(ExecuteOpenFileSettings, p => !IsExecutionRunning && p is ConversionQueueItem i && i.MediaAnalysis is not null); + OpenTrackSettingsCommand = new RelayCommand(ExecuteOpenTrackSettingsFromKeyboard, CanOpenTrackSettingsFromKeyboard); + OpenBulkFileConversionSettingsCommand = new RelayCommand(ExecuteOpenBulkFileSettings, CanOpenBulkFileSettings); + StartProcessingCommand = new RelayCommand(ExecuteStartProcessing, () => !IsExecutionRunning); + StopProcessingCommand = new RelayCommand(ExecuteStopProcessing, () => IsExecutionRunning); + CopyQueueItemErrorCommand = new RelayCommand(ExecuteCopyQueueItemError, CanCopyQueueItemError); + SaveQueueCommand = new RelayCommand(ExecuteSaveQueue, () => !IsExecutionRunning); + LoadQueueCommand = new RelayCommand(ExecuteLoadQueue, () => !IsExecutionRunning); + CloseToastCommand = new RelayCommand(HideToastInstant); + } + + public RelayCommand AddFilesCommand { get; } + public RelayCommand AddFolderCommand { get; } + public RelayCommand RemoveSelectedFromQueueCommand { get; } + public RelayCommand RemoveSelectedQueueItemsCommand { get; } + public RelayCommand ShowInFolderCommand { get; } + public RelayCommand PlayFileCommand { get; } + public RelayCommand ClearQueueCommand { get; } + public RelayCommand ClearCompletedFromQueueCommand { get; } + public RelayCommand OpenFileConversionSettingsCommand { get; } + public RelayCommand OpenTrackSettingsCommand { get; } + public RelayCommand OpenBulkFileConversionSettingsCommand { get; } + public RelayCommand StartProcessingCommand { get; } + public RelayCommand StopProcessingCommand { get; } + public RelayCommand CopyQueueItemErrorCommand { get; } + public RelayCommand SaveQueueCommand { get; } + public RelayCommand LoadQueueCommand { get; } + public RelayCommand CloseToastCommand { get; } + + public string ToastMessage + { + get => _toastMessage; + private set + { + if (_toastMessage == value) + { + return; + } + + _toastMessage = value; + OnPropertyChanged(); + } + } + + public bool IsToastVisible + { + get => _isToastVisible; + private set + { + if (_isToastVisible == value) + { + return; + } + + _isToastVisible = value; + OnPropertyChanged(); + } + } + + public ToastKind ToastKind + { + get => _toastKind; + private set + { + if (_toastKind == value) + { + return; + } + + _toastKind = value; + OnPropertyChanged(); + } + } + + public bool CopyQueueItemErrorMenuVisible + { + get => _copyQueueItemErrorMenuVisible; + private set + { + if (_copyQueueItemErrorMenuVisible == value) + { + return; + } + + _copyQueueItemErrorMenuVisible = value; + OnPropertyChanged(); + } + } + + public void RefreshCopyQueueItemErrorMenuState(IList? selected) + { + _selectedQueueItems.Clear(); + if (selected is not null) + { + _selectedQueueItems.AddRange(selected.OfType()); + } + + CopyQueueItemErrorMenuVisible = ConversionQueueItemErrorCopy.ShouldShowForSelection(selected); + CopyQueueItemErrorCommand.RaiseCanExecuteChanged(); + OpenTrackSettingsCommand.RaiseCanExecuteChanged(); + OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); + } + + /// Краткая строка для единого прогресса (например «Конвертация файла 3 из 12...»). + public string ExecutionPhaseCaption + { + get => _executionPhaseCaption; + private set + { + if (_executionPhaseCaption == value) + { + return; + } + + _executionPhaseCaption = value; + OnPropertyChanged(); + } + } + + /// Отображаемый общий прогресс (Floor от средних DisplayProgressPercent; без 100%, пока есть активные задачи). + public int OverallProgressPercent + { + get => _overallProgressPercent; + private set + { + if (_overallProgressPercent == value) + { + return; + } + + _overallProgressPercent = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(OverallProgressPercentLabel)); + } + } + + public int CompletedCount + { + get => _completedCount; + private set + { + if (_completedCount == value) + { + return; + } + + _completedCount = value; + OnPropertyChanged(); + } + } + + public int TotalCount + { + get => _totalCount; + private set + { + if (_totalCount == value) + { + return; + } + + _totalCount = value; + OnPropertyChanged(); + } + } + + /// Все задачи в очереди (для сводки рядом с прогрессом). + public int OverallQueueTotal + { + get => _overallQueueTotal; + private set + { + if (_overallQueueTotal == value) + { + return; + } + + _overallQueueTotal = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasQueueTasks)); + } + } + + /// Завершённые со статусом «Готово». + public int OverallQueueDoneCount + { + get => _overallQueueDoneCount; + private set + { + if (_overallQueueDoneCount == value) + { + return; + } + + _overallQueueDoneCount = value; + OnPropertyChanged(); + } + } + + /// Задачи со статусом «Ошибка». + public int OverallQueueErrorCount + { + get => _overallQueueErrorCount; + private set + { + if (_overallQueueErrorCount == value) + { + return; + } + + _overallQueueErrorCount = value; + OnPropertyChanged(); + } + } + + public bool HasQueueTasks => OverallQueueTotal > 0; + + public string OverallProgressPercentLabel => $"{OverallProgressPercent}%"; + + public string? CurrentRunId => _currentRunId; + + public bool IsExecutionRunning + { + get => _isExecutionRunning; + private set + { + if (_isExecutionRunning == value) + { + return; + } + + _isExecutionRunning = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(CanEditQueue)); + AddFilesCommand.RaiseCanExecuteChanged(); + AddFolderCommand.RaiseCanExecuteChanged(); + RemoveSelectedFromQueueCommand.RaiseCanExecuteChanged(); + RemoveSelectedQueueItemsCommand.RaiseCanExecuteChanged(); + ShowInFolderCommand.RaiseCanExecuteChanged(); + PlayFileCommand.RaiseCanExecuteChanged(); + CopyQueueItemErrorCommand.RaiseCanExecuteChanged(); + ClearQueueCommand.RaiseCanExecuteChanged(); + OpenFileConversionSettingsCommand.RaiseCanExecuteChanged(); + OpenTrackSettingsCommand.RaiseCanExecuteChanged(); + OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); + StartProcessingCommand.RaiseCanExecuteChanged(); + StopProcessingCommand.RaiseCanExecuteChanged(); + SaveQueueCommand.RaiseCanExecuteChanged(); + LoadQueueCommand.RaiseCanExecuteChanged(); + ClearCompletedFromQueueCommand.RaiseCanExecuteChanged(); + } + } + + public bool CanEditQueue => !IsExecutionRunning; + + public ConversionQueueItem? SelectedQueueItem + { + get => _selectedQueueItem; + set + { + if (ReferenceEquals(_selectedQueueItem, value)) + { + return; + } + + _selectedQueueItem = value; + OnPropertyChanged(); + ShowInFolderCommand.RaiseCanExecuteChanged(); + PlayFileCommand.RaiseCanExecuteChanged(); + CopyQueueItemErrorCommand.RaiseCanExecuteChanged(); + OpenTrackSettingsCommand.RaiseCanExecuteChanged(); + RemoveSelectedQueueItemsCommand.RaiseCanExecuteChanged(); + } + } + + public ConversionProfilePresetRow? SelectedDefaultProfile + { + get => _selectedDefaultProfile; + set + { + if (ReferenceEquals(_selectedDefaultProfile, value)) + { + return; + } + + _selectedDefaultProfile = value; + if (value is not null) + { + _defaultQueueProfile = value.Profile; + } + + OnPropertyChanged(); + OnPropertyChanged(nameof(DefaultQueueProfile)); + } + } + + public string DefaultQueueProfile => _defaultQueueProfile; + + /// Автоматически применять настройки дорожек из snapshot предыдущего настроенного файла (если структура совпадает). + public bool CopyPreviousTrackSettings + { + get => _copyPreviousTrackSettings; + set + { + if (_copyPreviousTrackSettings == value) + { + return; + } + + _copyPreviousTrackSettings = value; + OnPropertyChanged(); + } + } + + public bool DisableSubtitleDefault + { + get => _disableSubtitleDefault; + set + { + if (_disableSubtitleDefault == value) + { + return; + } + + _disableSubtitleDefault = value; + OnPropertyChanged(); + ReapplySubtitleDefaultRuleToAnalyzedTasks(); + } + } + + public void SyncDefaultProfileFromList(IReadOnlyList profiles) + { + if (profiles is null || profiles.Count == 0) + { + if (_selectedDefaultProfile is not null) + { + _selectedDefaultProfile = null; + OnPropertyChanged(nameof(SelectedDefaultProfile)); + } + + return; + } + + var match = profiles.FirstOrDefault(p => p.Profile.Equals(_defaultQueueProfile, StringComparison.OrdinalIgnoreCase)) + ?? profiles.FirstOrDefault(p => p.Profile.Equals("Emby", StringComparison.OrdinalIgnoreCase)) + ?? profiles[0]; + if (ReferenceEquals(_selectedDefaultProfile, match) && string.Equals(_defaultQueueProfile, match.Profile, StringComparison.Ordinal)) + { + return; + } + + _selectedDefaultProfile = match; + _defaultQueueProfile = match.Profile; + OnPropertyChanged(nameof(SelectedDefaultProfile)); + OnPropertyChanged(nameof(DefaultQueueProfile)); + } + + public bool IsQueueDropHighlight + { + get => _isQueueDropHighlight; + set + { + if (_isQueueDropHighlight == value) + { + return; + } + + _isQueueDropHighlight = value; + OnPropertyChanged(); + } + } + + public void ProcessPathsDroppedOnQueue(string[]? paths) + { + if (paths is null || paths.Length == 0) + { + return; + } + + var addOptions = ShowAddFilesOptionsDialog(); + if (addOptions is null) + { + return; + } + + EnqueueFromFileSystemPathArray(paths, "перетаскивание", addOptions, snapshotScopeExplicitRoot: null); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void OnQueueCollectionChanged(object? s, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is not null) + { + foreach (ConversionQueueItem item in e.NewItems) + { + item.PropertyChanged += OnQueueItemPropertyChanged; + } + } + + if (e.OldItems is not null) + { + foreach (ConversionQueueItem item in e.OldItems) + { + item.PropertyChanged -= OnQueueItemPropertyChanged; + } + } + + ShowInFolderCommand.RaiseCanExecuteChanged(); + PlayFileCommand.RaiseCanExecuteChanged(); + TouchClearCommand(); + OpenTrackSettingsCommand.RaiseCanExecuteChanged(); + OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); + RecalculateOverallProgress(); + } + + private void OnQueueItemPropertyChanged(object? s, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ConversionQueueItem.Profile) && s is ConversionQueueItem item) + { + OnTaskProfileChanged(item); + } + if (e.PropertyName is nameof(ConversionQueueItem.Progress) or nameof(ConversionQueueItem.Status) or nameof(ConversionQueueItem.FullPath)) + { + ShowInFolderCommand.RaiseCanExecuteChanged(); + PlayFileCommand.RaiseCanExecuteChanged(); + ClearCompletedFromQueueCommand.RaiseCanExecuteChanged(); + OpenTrackSettingsCommand.RaiseCanExecuteChanged(); + OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); + RecalculateOverallProgress(); + } + } + + private void OnTaskProfileChanged(ConversionQueueItem item) + { + if (item.MediaAnalysis is null) + { + return; + } + + if (IsExecutionRunning) + { + return; + } + + if (item.Status is ConversionQueueStatus.Analyzing or ConversionQueueStatus.Error + or ConversionQueueStatus.Cancelled) + { + return; + } + + var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; + TrackOverrideSeeder.SyncTargetFieldsFromProfile(item.TaskOverride, prof); + var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles); + item.SetPlan(plan); + if (item.Status is ConversionQueueStatus.Done or ConversionQueueStatus.Error or ConversionQueueStatus.Cancelled) + { + ResetTaskForReprocessing(item); + _logging.Info($"Задача возвращена в очередь после изменения настроек: {item.FullPath}", "conversion.queue"); + } + } + + public void RecalculateAllAnalyzedForProfileUpdate() + { + foreach (var item in QueueTasks) + { + if (item.MediaAnalysis is not null + && (string.Equals(item.Status, ConversionQueueStatus.Pending, StringComparison.Ordinal) + || string.Equals(item.Status, ConversionQueueStatus.Ready, StringComparison.Ordinal))) + { + var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; + // Keep per-file manual overrides intact; only untouched tasks follow updated profile targets. + if (!item.IsManuallyEdited) + { + TrackOverrideSeeder.SyncTargetFieldsFromProfile(item.TaskOverride, prof); + } + + if (DisableSubtitleDefault) + { + foreach (var subtitle in item.TaskOverride.TrackOverrides.Where(t => t.StreamKind == MediaStreamKind.Subtitle)) + { + subtitle.Default = false; + } + } + + var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles); + item.SetPlan(plan); + } + } + } + + private void ExecuteOpenFileSettings(object? parameter) + { + if (IsExecutionRunning) + { + return; + } + + if (parameter is not ConversionQueueItem item || item.MediaAnalysis is null) + { + return; + } + + var w = new FileConversionSettingsWindow + { + Owner = Application.Current?.MainWindow + }; + w.DataContext = new FileConversionSettingsViewModel( + item, + _planService, + _profile, + _trackSnapshotService, + _logging, + FormOptions, + CopyPreviousTrackSettings, + OnFileSettingsSaved, + () => w.Close()); + w.ShowDialog(); + } + + private bool CanOpenTrackSettingsFromKeyboard(object? parameter) + { + if (IsExecutionRunning) + { + return false; + } + + var item = ResolveItemForTrackSettings(parameter); + return item is not null + && item.MediaAnalysis is not null + && item.Status is not (ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing); + } + + private void ExecuteOpenTrackSettingsFromKeyboard(object? parameter) + { + var item = ResolveItemForTrackSettings(parameter); + if (!CanOpenTrackSettingsFromKeyboard(parameter) || item is null) + { + return; + } + + _logging.Debug("Открыты настройки дорожек через F2", "conversion.keyboard"); + ExecuteOpenFileSettings(item); + } + + private ConversionQueueItem? ResolveItemForTrackSettings(object? parameter) + { + if (parameter is IList list && list.Count > 0) + { + if (_selectedQueueItem is not null && list.Contains(_selectedQueueItem)) + { + return _selectedQueueItem; + } + + return list[list.Count - 1] as ConversionQueueItem; + } + + return _selectedQueueItem; + } + + private bool CanOpenBulkFileSettings(object? parameter) + { + if (IsExecutionRunning) + { + return false; + } + + var selected = ResolveBulkSelection(parameter); + if (selected.Count < 2) + { + return false; + } + + return selected.All(i => i.Status is not ( + ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing)); + } + + private void ExecuteOpenBulkFileSettings(object? parameter) + { + var selected = ResolveBulkSelection(parameter); + if (selected.Count < 2) + { + return; + } + + _logging.Info($"массовая настройка: выбрано строк {selected.Count}", "conversion.bulk"); + var analysis = _bulkTrackSettingsService.Analyze(selected); + if (!analysis.HasMajority || analysis.MajorityItems.Count < 2) + { + ShowToast("Невозможно выполнить массовую настройку: структуры дорожек слишком различаются.", ToastKind.Warning); + _logging.Warning("массовая настройка: большинство не определено", "conversion.bulk"); + return; + } + + var skippedRows = analysis.SkippedItems.Select(i => i.OrderNumber).OrderBy(i => i).ToList(); + _logging.Info( + $"массовая настройка: основной структуры {analysis.MajorityItems.Count}, отличаются: {FormatRowList(skippedRows)}", + "conversion.bulk"); + + if (skippedRows.Count > 0) + { + ShowToast($"Строки № {FormatRowList(skippedRows)} отличаются от большинства и не будут изменены.", ToastKind.Warning); + } + + var representative = analysis.MajorityItems.OrderBy(i => i.OrderNumber).First(); + var dialog = new BulkFileConversionSettingsWindow + { + Owner = Application.Current?.MainWindow + }; + dialog.DataContext = new BulkFileConversionSettingsViewModel( + representative, + analysis.MajorityItems, + FormOptions, + editedTemplateTracks => ApplyBulkEdits(analysis, editedTemplateTracks), + () => dialog.Close()); + dialog.ShowDialog(); + } + + private void ApplyBulkEdits(BulkTrackSelectionAnalysis analysis, IReadOnlyList editedTemplateTracks) + { + _bulkTrackSettingsService.ApplyBulkEdits(analysis.MajorityItems, editedTemplateTracks); + var affected = 0; + foreach (var item in analysis.MajorityItems) + { + var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var plan = _planService.Build(item.MediaAnalysis!, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles); + item.IsManuallyEdited = true; + item.SetPlan(plan); + + if (item.Status is ConversionQueueStatus.Done or ConversionQueueStatus.Error or ConversionQueueStatus.Cancelled) + { + ResetTaskForReprocessing(item); + } + else + { + item.Progress = 0; + } + + affected++; + } + + var skippedRows = analysis.SkippedItems.Select(i => i.OrderNumber).OrderBy(i => i).ToList(); + var message = skippedRows.Count == 0 + ? $"Массовые настройки применены к {affected} файлам" + : $"Массовые настройки применены к {affected} файлам. Пропущены строки: {FormatRowList(skippedRows)}"; + _logging.Info(message, "conversion.bulk"); + ShowToast(message, ToastKind.Success); + } + + private List ResolveBulkSelection(object? parameter) + { + if (parameter is IList list) + { + return list.OfType().Distinct().ToList(); + } + + return _selectedQueueItems.Distinct().ToList(); + } + + private static string FormatRowList(IReadOnlyList rows) => + rows.Count == 0 ? "—" : string.Join(", ", rows); + + private void ShowToast(string message, ToastKind kind) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + _toastHideTimer?.Stop(); + _toastHideTimer = null; + + ToastMessage = message.Trim(); + ToastKind = kind; + IsToastVisible = true; + + var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + _toastHideTimer = new DispatcherTimer( + TimeSpan.FromSeconds(3), + DispatcherPriority.Background, + (_, _) => HideToastInstant(), + dispatcher); + } + + private void HideToastInstant() + { + _toastHideTimer?.Stop(); + _toastHideTimer = null; + IsToastVisible = false; + ToastMessage = string.Empty; + } + + private void OnFileSettingsSaved(ConversionQueueItem item, bool hasChangesFromCurrent) + { + if (!hasChangesFromCurrent) + { + return; + } + + if (item.Status is ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing) + { + return; + } + + ResetTaskForReprocessing(item); + _logging.Info($"Задача возвращена в очередь после изменения настроек: {item.FullPath}", "conversion.queue"); + } + + private void TouchClearCommand() + { + ClearQueueCommand.RaiseCanExecuteChanged(); + ClearCompletedFromQueueCommand.RaiseCanExecuteChanged(); + } + + /// Удаляются только задачи в статусе «Готово» (Done). Ошибки и отменённые остаются в очереди. + private static bool IsClearedWhenRemovingCompletedTasks(string status) => + string.Equals(status, ConversionQueueStatus.Done, StringComparison.Ordinal); + + private bool CanClearCompletedFromQueue() => + !IsExecutionRunning && QueueTasks.Any(t => IsClearedWhenRemovingCompletedTasks(t.Status)); + + private void ExecuteClearCompletedFromQueue() + { + if (!CanClearCompletedFromQueue()) + { + return; + } + + var removed = 0; + for (var i = QueueTasks.Count - 1; i >= 0; i--) + { + var item = QueueTasks[i]; + if (!IsClearedWhenRemovingCompletedTasks(item.Status)) + { + continue; + } + + _queuedPaths.Remove(item.FullPath); + QueueTasks.RemoveAt(i); + removed++; + } + + if (removed == 0) + { + return; + } + + RenumberQueue(); + _logging.Info($"очередь: удалено завершённых задач: {removed}, осталось: {QueueTasks.Count}", "conversion.queue"); + TouchClearCommand(); + RecalculateOverallProgress(); + } + + private async void ExecuteStartProcessing() + { + if (IsExecutionRunning) + { + return; + } + + var preparedResetCount = 0; + foreach (var item in QueueTasks) + { + if (string.Equals(item.Status, ConversionQueueStatus.Error, StringComparison.Ordinal) + || string.Equals(item.Status, ConversionQueueStatus.Cancelled, StringComparison.Ordinal)) + { + PrepareErrorOrCancelledTaskForQueuedRetry(item); + preparedResetCount++; + } + } + + var runItems = QueueTasks.Where(IsEligibleForConversionRunStatus).ToList(); + + TryAutoSaveQueueBeforeRun(); + + _logging.Info($"задач для повторного запуска: {runItems.Count} (из «Ошибка»/«Отмена» подготовлено: {preparedResetCount})", "conversion.queue"); + + if (runItems.Count == 0) + { + return; + } + + IsExecutionRunning = true; + _execCts = new CancellationTokenSource(); + var token = _execCts.Token; + IReadOnlyList? runSnapshot = null; + var countingRunId = string.Empty; + var cancelledByUser = false; + try + { + runSnapshot = runItems; + var runId = Guid.NewGuid().ToString("N"); + countingRunId = runId; + _currentRunId = runId; + OnPropertyChanged(nameof(CurrentRunId)); + _currentRunItems = runItems.ToHashSet(); + foreach (var item in QueueTasks) + { + item.ProcessedInCurrentRun = false; + } + + RecalculateOverallProgress(); + await _execution.RunQueueAsync( + runItems, + name => _profile.GetProfile(name), + _tempDirectoryProvider(), + runId, + RunOnUiActionAsync, + token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + cancelledByUser = true; + _logging.Warning("обработка очереди остановлена пользователем", "conversion.exec"); + } + finally + { + NotifyConversionQueueEnded(runSnapshot, countingRunId, cancelledByUser); + await RunOnUiActionAsync( + () => + { + _currentRunId = null; + OnPropertyChanged(nameof(CurrentRunId)); + _currentRunItems = new HashSet(); + IsExecutionRunning = false; + RecalculateOverallProgress(); + }) + .ConfigureAwait(false); + _execCts?.Dispose(); + _execCts = null; + } + } + + private void NotifyConversionQueueEnded( + IReadOnlyList? runSnapshot, + string countingRunId, + bool cancelledByUser) + { + try + { + if (runSnapshot is null || runSnapshot.Count == 0 || string.IsNullOrEmpty(countingRunId)) + { + return; + } + + if (cancelledByUser) + { + _notifications.NotifyQueueCancelled(); + return; + } + + var successCount = runSnapshot.Count(i => + string.Equals(i.LastRunId, countingRunId, StringComparison.OrdinalIgnoreCase) + && string.Equals(i.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)); + + var errorCount = runSnapshot.Count(i => + string.Equals(i.LastRunId, countingRunId, StringComparison.OrdinalIgnoreCase) + && string.Equals(i.Status, ConversionQueueStatus.Error, StringComparison.Ordinal)); + + if (successCount == 0 && errorCount == 0) + { + return; + } + + _notifications.NotifyQueueCompleted(successCount, errorCount); + } + catch (Exception ex) + { + _logging.Warning($"завершение уведомлений очереди: {ex.Message}", "notify", ex); + } + } + + private void ExecuteStopProcessing() + { + _execCts?.Cancel(); + } + + private static bool IsEligibleForConversionRunStatus(ConversionQueueItem item) => + string.Equals(item.Status, ConversionQueueStatus.Pending, StringComparison.Ordinal) + || string.Equals(item.Status, ConversionQueueStatus.Ready, StringComparison.Ordinal); + + private static void PrepareErrorOrCancelledTaskForQueuedRetry(ConversionQueueItem item) + { + item.Status = ConversionQueueStatus.Pending; + item.Progress = 0; + item.ErrorMessage = null; + item.ErrorDetails = null; + item.IsProcessed = false; + item.ProcessedInCurrentRun = false; + item.LastRunId = null; + } + + private void TryAutoSaveQueueBeforeRun() + { + try + { + var path = ConversionQueueSetupPersistence.AllocateAutoSavePath(); + var doc = BuildQueueSetupRoot(); + ConversionQueueSetupPersistence.SaveToPath(path, doc); + _logging.Info($"очередь автоматически сохранена перед запуском. Файл: {path}", "conversion.queue"); + } + catch (Exception ex) + { + _logging.Warning($"автосохранение очереди перед запуском не выполнено: {ex.Message}", "conversion.queue", ex); + } + } + + private ConversionQueueSetupRoot BuildQueueSetupRoot() + { + var form = new ConversionFormOptionsSnapshot + { + ContainerOptions = FormOptions.ContainerOptions.ToList(), + VideoCodecOptions = FormOptions.VideoCodecOptions.ToList(), + PixelFormatOptions = FormOptions.PixelFormatOptions.ToList(), + ResolutionOptions = FormOptions.ResolutionOptions.ToList(), + FpsOptions = FormOptions.FpsOptions.ToList(), + AudioBitrateKbps = FormOptions.AudioBitrateKbps.ToList(), + VideoBitrateModeOptions = FormOptions.VideoBitrateModeOptions.ToList(), + }; + + return new ConversionQueueSetupRoot + { + SchemaVersion = 1, + SavedAtUtc = DateTime.UtcNow, + DefaultQueueProfile = DefaultQueueProfile, + CopyPreviousTrackSettings = CopyPreviousTrackSettings, + DisableSubtitleDefault = DisableSubtitleDefault, + FormOptions = form, + Profiles = _profilesSnapshotForSetup(), + Tasks = QueueTasks.Select(ToPersistTaskModel).ToList(), + }; + } + + private static ConversionQueueTaskPersistModel ToPersistTaskModel(ConversionQueueItem item) + { + return new ConversionQueueTaskPersistModel + { + FullPath = item.FullPath, + SnapshotScopeBatchRoot = item.SnapshotScopeBatchRoot, + OrderNumber = item.OrderNumber, + Profile = item.Profile, + PlanSummary = item.PlanSummary, + Status = item.Status, + Progress = item.Progress, + IsManuallyEdited = item.IsManuallyEdited, + IsProcessed = item.IsProcessed, + ProcessedInCurrentRun = item.ProcessedInCurrentRun, + LastRunId = item.LastRunId, + ErrorMessage = item.ErrorMessage, + ErrorDetails = item.ErrorDetails, + FileSizeMb = item.FileSizeMb, + HasFfprobeAudioSummary = item.HasFfprobeAudioSummary, + FfprobeAudioCount = item.FfprobeEmbeddedAudioStreamCount, + FfprobeAudioSizeMb = item.FfprobeAudioSizeEstimateMb, + FfprobeAudioSizePartial = item.FfprobeAudioSizeEstimatePartial, + MediaAnalysis = item.MediaAnalysis, + Sidecars = item.Sidecars.Select(SidecarFilePersistModel.From).ToList(), + ExternalAudioFiles = item.ExternalAudioFiles.Select(ExternalAudioFilePersistModel.From).ToList(), + Overrides = ConversionTaskOverridePersistModel.From(item.TaskOverride), + }; + } + + private void ExecuteSaveQueue() + { + if (IsExecutionRunning) + { + return; + } + + var dialog = new SaveFileDialog + { + Title = "Сохранить очередь конвертации", + Filter = $"Настройка очереди (*{ConversionQueueSetupPersistence.FileExtension})|*{ConversionQueueSetupPersistence.FileExtension}", + FileName = + $"conversion-setup-{DateTime.Now:yyyy-MM-dd-HH-mm-ss}{ConversionQueueSetupPersistence.FileExtension}", + InitialDirectory = ConversionQueueSetupPersistence.GetQueueSetupsDirectory(), + }; + + if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FileName)) + { + return; + } + + try + { + var doc = BuildQueueSetupRoot(); + ConversionQueueSetupPersistence.SaveToPath(dialog.FileName, doc); + _logging.Info($"очередь сохранена в файл: {dialog.FileName}", "conversion.queue"); + } + catch (Exception ex) + { + _logging.Error($"сохранение очереди: {ex.Message}", "conversion.queue", ex); + } + } + + private void ExecuteLoadQueue() + { + if (IsExecutionRunning) + { + return; + } + + var dialog = new OpenFileDialog + { + Title = "Загрузить очередь конвертации", + Filter = + $"Настройка очереди (*{ConversionQueueSetupPersistence.FileExtension})|*{ConversionQueueSetupPersistence.FileExtension}|Все файлы|*.*", + InitialDirectory = ConversionQueueSetupPersistence.GetQueueSetupsDirectory(), + }; + + if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FileName)) + { + return; + } + + ConversionQueueSetupRoot doc; + try + { + doc = ConversionQueueSetupPersistence.LoadFromPath(dialog.FileName); + } + catch (Exception ex) + { + _logging.Error($"загрузка .conv_setup: {ex.Message}", "conversion.queue", ex); + return; + } + + if (doc.Profiles is { Count: > 0 }) + { + _applyProfilesFromSetupDocument?.Invoke(doc.Profiles); + } + + CopyPreviousTrackSettings = doc.CopyPreviousTrackSettings; + DisableSubtitleDefault = doc.DisableSubtitleDefault; + var opts = doc.FormOptions ?? new ConversionFormOptionsSnapshot(); + FormOptions.RestoreListsFromSerialized( + opts.ContainerOptions, + opts.VideoCodecOptions, + opts.PixelFormatOptions, + opts.ResolutionOptions, + opts.FpsOptions, + opts.AudioBitrateKbps, + opts.VideoBitrateModeOptions); + + if (!string.IsNullOrWhiteSpace(doc.DefaultQueueProfile)) + { + _defaultQueueProfile = doc.DefaultQueueProfile.Trim(); + OnPropertyChanged(nameof(DefaultQueueProfile)); + } + + SyncDefaultProfileFromList(_presetRowsForSetup()); + + _analysisCts?.Cancel(); + QueueTasks.Clear(); + _queuedPaths.Clear(); + + var restored = 0; + foreach (var t in doc.Tasks ?? []) + { + restored++; + string full; + try + { + full = Path.GetFullPath(t.FullPath); + } + catch + { + full = t.FullPath; + } + + var item = new ConversionQueueItem(full); + item.SnapshotScopeBatchRoot = t.SnapshotScopeBatchRoot; + item.OrderNumber = t.OrderNumber; + item.Profile = string.IsNullOrWhiteSpace(t.Profile) ? "Emby" : t.Profile; + item.IsManuallyEdited = t.IsManuallyEdited; + if (t.Overrides is not null) + { + t.Overrides.ApplyTo(item.TaskOverride); + } + + if (!File.Exists(item.FullPath)) + { + item.Status = ConversionQueueStatus.Error; + item.Progress = 0; + item.ErrorMessage = "Файл не найден"; + item.ErrorDetails = null; + item.PlanSummary = string.IsNullOrWhiteSpace(t.PlanSummary) ? "—" : t.PlanSummary; + } + else + { + item.RefreshFileSizeFromDisk(); + if (t.MediaAnalysis is null) + { + item.Status = ConversionQueueStatus.Error; + item.Progress = 0; + item.ErrorMessage = "Нет сохранённых данных анализа."; + item.ErrorDetails = null; + item.PlanSummary = string.IsNullOrWhiteSpace(t.PlanSummary) ? "—" : t.PlanSummary; + } + else + { + var sidecars = (t.Sidecars ?? []).Select(s => s.ToModel()).ToList(); + var ext = (t.ExternalAudioFiles ?? []).Select(e => e.ToModel()).ToList(); + item.RestorePersistedMediaSnapshot( + t.MediaAnalysis, + sidecars, + ext, + t.HasFfprobeAudioSummary, + t.FfprobeAudioCount, + t.FfprobeAudioSizeMb, + t.FfprobeAudioSizePartial); + + var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var plan = _planService.Build(item.MediaAnalysis!, sidecars, prof, item.TaskOverride, ext); + item.SetPlan(plan); + item.Status = NormalizeLoadedExecutionStatus(t.Status); + + if (string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)) + { + item.Progress = 100; + } + else + { + item.Progress = Math.Clamp(t.Progress, 0, 99); + } + + item.IsProcessed = t.IsProcessed; + if (string.Equals(item.Status, ConversionQueueStatus.Error, StringComparison.Ordinal)) + { + item.ErrorMessage = string.IsNullOrWhiteSpace(t.ErrorMessage) + ? "Ошибка" + : t.ErrorMessage.Trim(); + item.ErrorDetails = t.ErrorDetails; + } + else + { + item.ErrorMessage = null; + item.ErrorDetails = null; + } + } + } + + item.ProcessedInCurrentRun = false; + item.LastRunId = null; + + QueueTasks.Add(item); + _queuedPaths.Add(item.FullPath); + } + + RenumberQueue(); + TouchClearCommand(); + RecalculateOverallProgress(); + _logging.Info($"очередь загружена из файла: {dialog.FileName}. Восстановлено задач: {restored}", "conversion.queue"); + } + + private static string NormalizeLoadedExecutionStatus(string? saved) + { + if (string.IsNullOrWhiteSpace(saved)) + { + return ConversionQueueStatus.Pending; + } + + foreach (var transient in TransientStatusesNotRestoredAfterLoad()) + { + if (string.Equals(saved, transient, StringComparison.Ordinal)) + { + return ConversionQueueStatus.Pending; + } + } + + return saved; + } + + private static IEnumerable TransientStatusesNotRestoredAfterLoad() + { + yield return ConversionQueueStatus.Analyzing; + yield return ConversionQueueStatus.Running; + yield return ConversionQueueStatus.Copying; + yield return ConversionQueueStatus.Replacing; + } + + private void ExecuteAddFiles() + { + if (IsExecutionRunning) + { + return; + } + + var dialog = new OpenFileDialog + { + Title = "Выберите видеофайлы", + Multiselect = true, + Filter = "Видео файлы|*.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|Все файлы|*.*", + InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.ConversionAddFiles), + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + _recentPaths.RememberChosenFiles(RecentPathScenario.ConversionAddFiles, dialog.FileNames); + + var paths = FileDiscoveryService.SortVideoPathsByFullPath( + dialog.FileNames.Where(f => _discoveryService.IsSupportedVideoFile(f)).Select(Path.GetFullPath)); + + var addOptions = ShowAddFilesOptionsDialog(); + if (addOptions is null) + { + return; + } + + EnqueueFromFileSystemPathArray(paths, "добавить файлы", addOptions, snapshotScopeExplicitRoot: null); + } + + private void ExecuteAddFolder() + { + if (IsExecutionRunning) + { + return; + } + + var dialog = new OpenFolderDialog + { + Title = "Выберите каталог с видео", + InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.ConversionAddFolder), + }; + + if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName)) + { + return; + } + + _recentPaths.RememberChosenFolder(RecentPathScenario.ConversionAddFolder, dialog.FolderName); + + var fullRoot = Path.GetFullPath(dialog.FolderName); + var list = _discoveryService.DiscoverVideoFiles(fullRoot, err => _logging.Error(err, "conversion.discovery")); + var addOptions = ShowAddFilesOptionsDialog(); + if (addOptions is null) + { + return; + } + + EnqueueVideoFiles(list, "добавить каталог", addOptions, snapshotScopeExplicitRoot: fullRoot); + } + + private void ExecuteClearQueue() + { + if (IsExecutionRunning) + { + return; + } + + var n = QueueTasks.Count; + if (n == 0) + { + return; + } + + _analysisCts?.Cancel(); + QueueTasks.Clear(); + _queuedPaths.Clear(); + _logging.Info($"очередь очищена, удалено записей: {n}", "conversion.queue"); + TouchClearCommand(); + RecalculateOverallProgress(); + } + + private void EnqueueFromFileSystemPathArray( + IReadOnlyList pathEntries, + string opTag, + AddFilesOptions addOptions, + string? snapshotScopeExplicitRoot) + { + var videoPaths = _discoveryService.CollectVideoFilesFromFileSystemEntries(pathEntries, err => _logging.Error(err, "conversion.discovery")); + EnqueueVideoFiles(videoPaths, opTag, addOptions, snapshotScopeExplicitRoot); + } + + private void EnqueueVideoFiles( + IReadOnlyList videoPaths, + string opTag, + AddFilesOptions addOptions, + string? snapshotScopeExplicitRoot = null) + { + if (videoPaths.Count == 0) + { + _logging.Info("очередь: нет поддерживаемых видео для добавления", "conversion.queue"); + return; + } + + var profile = CurrentProfileNameForNewTasks(); + var added = 0; + var dups = 0; + var newBatch = new List(); + + List existingFullForScope = []; + foreach (var path in videoPaths) + { + var full = Path.GetFullPath(path); + if (!File.Exists(full)) + { + continue; + } + + existingFullForScope.Add(full); + } + + var inferredBatchScope = SnapshotScopePaths.TryGetLowestCommonAncestorDirectory(existingFullForScope); + string? batchScopeStored = string.IsNullOrWhiteSpace(snapshotScopeExplicitRoot) + ? inferredBatchScope + : SnapshotScopePaths.NormalizeScopeDirectory(snapshotScopeExplicitRoot); + batchScopeStored = string.IsNullOrWhiteSpace(batchScopeStored) ? null : batchScopeStored; + + foreach (var path in videoPaths) + { + var full = Path.GetFullPath(path); + if (!File.Exists(full)) + { + continue; + } + + if (!_queuedPaths.Add(full)) + { + dups++; + _logging.Info($"очередь: пропущен дубликат — {full}", "conversion.queue"); + continue; + } + + var item = new ConversionQueueItem(full) + { + SnapshotScopeBatchRoot = batchScopeStored, + OrderNumber = QueueTasks.Count + 1, + Status = ConversionQueueStatus.Analyzing, + Progress = 0, + Profile = profile, + PlanSummary = "Анализ…" + }; + QueueTasks.Add(item); + newBatch.Add(item); + added++; + } + + if (added > 0) + { + RenumberQueue(); + _ = RunAnalysisBatchesChainedAsync(newBatch, addOptions); + } + + TouchClearCommand(); + var found = videoPaths.Count; + _logging.Info( + $"очередь ({opTag}): найдено {found}, добавлено {added}, пропущено дубликатов: {dups}; порядок: сортировка по полному пути ({nameof(StringComparer.OrdinalIgnoreCase)}), всего в очереди: {QueueTasks.Count}", + "conversion.queue"); + RecalculateOverallProgress(); + } + + private string CurrentProfileNameForNewTasks() => + string.IsNullOrWhiteSpace(_defaultQueueProfile) ? "Emby" : _defaultQueueProfile; + + private void RenumberQueue() + { + for (var i = 0; i < QueueTasks.Count; i++) + { + var n = i + 1; + if (QueueTasks[i].OrderNumber != n) + { + QueueTasks[i].OrderNumber = n; + } + } + } + + private async Task RunAnalysisBatchesChainedAsync(IReadOnlyList batch, AddFilesOptions addOptions) + { + if (batch.Count == 0) + { + return; + } + + await _batchGate.WaitAsync().ConfigureAwait(false); + _analysisCts = new CancellationTokenSource(); + var cts = _analysisCts; + var token = cts.Token; + var autoRemoveForeignTracksForBatch = addOptions.RemoveForeignAudioAndSubtitles; + var disableSubtitleDefaultForBatch = DisableSubtitleDefault; + var app = Application.Current; + try + { + if (app?.Dispatcher is null) + { + return; + } + + await AwaitOnUiThreadAsync( + () => + { + AnalysisProgress.StartBatch(batch.Count); + return Task.CompletedTask; + }) + .ConfigureAwait(false); + + var progress = new Progress( + p => + { + if (app.Dispatcher.CheckAccess()) + { + AnalysisProgress.OnProgress(p); + } + else + { + app.Dispatcher.BeginInvoke( + (Action)(() => AnalysisProgress.OnProgress(p)), + DispatcherPriority.DataBind); + } + }); + + int errors; + try + { + errors = await _queueAnalysis.RunAsync( + batch, + item => QueueTasks.Contains(item), + autoRemoveForeignTracksForBatch, + disableSubtitleDefaultForBatch, + progress, + RunOnUiActionAsync, + token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logging.Error($"анализ очереди: {ex.Message}", "conversion.ffprobe", ex); + errors = 0; + } + + await AwaitOnUiThreadAsync( + () => AnalysisProgress.FinalizeAndHideAsync(errors)) + .ConfigureAwait(false); + } + finally + { + if (ReferenceEquals(_analysisCts, cts)) + { + cts.Dispose(); + _analysisCts = null; + } + + _batchGate.Release(); + } + } + + private static async Task AwaitOnUiThreadAsync(Func work) + { + var d = Application.Current?.Dispatcher; + if (d is null) + { + await work().ConfigureAwait(false); + return; + } + + if (d.CheckAccess()) + { + await work().ConfigureAwait(true); + return; + } + + var tcs = new TaskCompletionSource(); + + async Task RunOnUi() + { + try + { + await work().ConfigureAwait(true); + tcs.SetResult(); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + } + + // Fire-and-forget: RunOnUi completes the TCS; suppress CS4014 for the inner Task and BeginInvoke. +#pragma warning disable CS4014 + d.BeginInvoke( + (Action)(() => { _ = RunOnUi(); }), + DispatcherPriority.DataBind); +#pragma warning restore CS4014 + await tcs.Task.ConfigureAwait(false); + } + + private Task RunOnUiActionAsync(Action action) => + AwaitOnUiThreadAsync( + () => + { + action(); + return Task.CompletedTask; + }); + + private AddFilesOptions? ShowAddFilesOptionsDialog() + { + var dialog = new AddFilesOptionsDialog + { + Owner = Application.Current?.MainWindow + }; + + AddFilesOptions? selected = null; + var vm = new AddFilesOptionsViewModel( + options => + { + selected = options; + dialog.DialogResult = true; + dialog.Close(); + }, + () => + { + dialog.DialogResult = false; + dialog.Close(); + }); + dialog.DataContext = vm; + var accepted = dialog.ShowDialog() == true; + return accepted ? selected : null; + } + + private void RemoveSelectedFromQueue(object? parameter) + { + RemoveSelectedQueueItems(parameter); + } + + private bool CanRemoveSelectedQueueItems(object? parameter) => + !IsExecutionRunning + && parameter is IList { Count: > 0 } + && !IsInCellEditorContext(); + + private static bool IsInCellEditorContext() + { + var focused = Keyboard.FocusedElement; + return focused is System.Windows.Controls.TextBox + or System.Windows.Controls.Primitives.TextBoxBase + or System.Windows.Controls.ComboBox + or System.Windows.Controls.ComboBoxItem; + } + + private void RemoveSelectedQueueItems(object? parameter) + { + if (!CanRemoveSelectedQueueItems(parameter) || parameter is not IList { Count: > 0 } list) + { + return; + } + + var selected = list.OfType() + .Select(item => new { Item = item, Index = QueueTasks.IndexOf(item) }) + .Where(x => x.Index >= 0) + .OrderBy(x => x.Index) + .ToList(); + if (selected.Count == 0) + { + return; + } + + var busyCount = 0; + var removable = new List<(ConversionQueueItem Item, int Index)>(); + foreach (var x in selected) + { + if (x.Item.Status is ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing) + { + busyCount++; + } + else + { + removable.Add((x.Item, x.Index)); + } + } + + if (removable.Count == 0) + { + if (busyCount > 0) + { + _logging.Warning($"Не удалено задач: {busyCount}, так как они выполняются", "conversion.queue"); + } + + return; + } + + var anchorIndex = removable.Min(x => x.Index); + foreach (var (item, _) in removable.OrderByDescending(x => x.Index)) + { + _queuedPaths.Remove(item.FullPath); + QueueTasks.Remove(item); + } + + RenumberQueue(); + if (QueueTasks.Count > 0) + { + var nextIndex = Math.Min(anchorIndex, QueueTasks.Count - 1); + SelectedQueueItem = QueueTasks[nextIndex]; + } + else + { + SelectedQueueItem = null; + } + + _logging.Info($"Удалено задач из очереди: {removable.Count}", "conversion.queue"); + if (busyCount > 0) + { + _logging.Warning($"Не удалено задач: {busyCount}, так как они выполняются", "conversion.queue"); + } + + TouchClearCommand(); + RecalculateOverallProgress(); + } + + private bool CanShowInFolder(object? parameter) + { + if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) + { + return false; + } + + return FolderCommandsAllowedFor(item) && File.Exists(GetTaskVideoPath(item)); + } + + private bool CanPlayFile(object? parameter) + { + if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) + { + return false; + } + + return FolderCommandsAllowedFor(item) && File.Exists(GetTaskVideoPath(item)); + } + + private void ExecuteShowInFolder(object? parameter) + { + if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) + { + return; + } + + if (!FolderCommandsAllowedFor(item)) + { + return; + } + + var path = GetTaskVideoPath(item); + try + { + path = Path.GetFullPath(path); + } + catch (Exception ex) + { + _logging.Warning($"некорректный путь для «Показать в папке»: {path} ({ex.Message})", "conversion.queue"); + return; + } + + if (!File.Exists(path)) + { + _logging.Warning($"файл не найден — «Показать в папке»: {path}", "conversion.queue"); + return; + } + + try + { + var escaped = path.Replace("\"", "\\\"", StringComparison.Ordinal); + var args = $@"/select,""{escaped}"""; + Process.Start( + new ProcessStartInfo("explorer.exe", args) + { + UseShellExecute = true, + }); + } + catch (Exception ex) + { + _logging.Error($"Не удалось открыть Проводник для файла «{path}»: {ex.Message}", "conversion.queue", ex); + } + } + + private void ExecutePlayFile(object? parameter) + { + if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) + { + return; + } + + if (!FolderCommandsAllowedFor(item)) + { + return; + } + + var path = GetTaskVideoPath(item); + try + { + path = Path.GetFullPath(path); + } + catch (Exception ex) + { + _logging.Warning($"некорректный путь для «Воспроизвести»: {path} ({ex.Message})", "conversion.queue"); + return; + } + + if (!File.Exists(path)) + { + _logging.Warning($"файл не найден — «Воспроизвести»: {path}", "conversion.queue"); + return; + } + + try + { + Process.Start( + new ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + _logging.Error($"Не удалось воспроизвести файл «{path}»: {ex.Message}", "conversion.queue", ex); + } + } + + private bool CanCopyQueueItemError(object? parameter) => + ConversionQueueItemErrorCopy.ShouldShowForSelection(parameter as IList); + + private void ExecuteCopyQueueItemError(object? parameter) + { + if (!TryGetExactlyOneQueueItem(parameter, out var item) + || item is null + || !ConversionQueueItemErrorCopy.IsEligibleItem(item)) + { + return; + } + + var text = ConversionQueueItemErrorCopy.GetClipboardText(item); + if (string.IsNullOrEmpty(text)) + { + return; + } + + try + { + Clipboard.SetText(text); + } + catch (Exception ex) + { + _logging.Warning($"Не удалось скопировать текст в буфер обмена: {ex.Message}", "conversion.queue"); + return; + } + + _logging.Info($"Ошибка скопирована в буфер обмена: {item.FileName}", "conversion.queue"); + } + + private static string GetTaskVideoPath(ConversionQueueItem item) => item.FullPath; + + private static bool FolderCommandsAllowedFor(ConversionQueueItem item) => + item.Status is not (ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing); + + /// Ровно одна выбранная строка очереди (параметр — SelectedItems с DataGrid). + private static bool TryGetExactlyOneQueueItem(object? parameter, out ConversionQueueItem? item) + { + item = null; + if (parameter is not IList { Count: 1 } list) + { + return false; + } + + item = list[0] as ConversionQueueItem; + return item is not null; + } + + private static void ResetTaskForReprocessing(ConversionQueueItem item) + { + item.Status = ConversionQueueStatus.Pending; + item.Progress = 0; + item.IsProcessed = false; + item.ProcessedInCurrentRun = false; + item.LastRunId = null; + item.ErrorMessage = null; + item.ErrorDetails = null; + } + + private void RecalculateOverallProgress() + { + var all = QueueTasks.ToList(); + static bool IsDone(ConversionQueueItem i) => + string.Equals(i.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); + static bool IsErr(ConversionQueueItem i) => + string.Equals(i.Status, ConversionQueueStatus.Error, StringComparison.Ordinal); + + var qTotal = all.Count; + OverallQueueTotal = qTotal; + OverallQueueDoneCount = all.Count(IsDone); + OverallQueueErrorCount = all.Count(IsErr); + + if (qTotal == 0) + { + TotalCount = 0; + CompletedCount = 0; + OverallProgressPercent = 0; + ExecutionPhaseCaption = string.Empty; + return; + } + + CompletedCount = OverallQueueDoneCount; + TotalCount = qTotal; + + var runScope = IsExecutionRunning && _currentRunItems.Count > 0 + ? _currentRunItems.Where(QueueTasks.Contains).ToList() + : null; + var effectiveScope = runScope is { Count: > 0 } ? runScope : all; + + var sumDisplay = 0; + foreach (var item in effectiveScope) + { + sumDisplay += item.DisplayProgressPercent; + } + + var denom = Math.Max(1, effectiveScope.Count); + var avgFloor = (int)Math.Floor(sumDisplay / (double)denom); + var anyBusyForCap = effectiveScope.Any(IsBusyForOverallProgressCap); + OverallProgressPercent = anyBusyForCap ? Math.Min(99, avgFloor) : avgFloor; + + if (!IsExecutionRunning || runScope is not { Count: > 0 }) + { + ExecutionPhaseCaption = string.Empty; + } + else + { + static bool IsDoneRun(ConversionQueueItem i) => + string.Equals(i.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); + var totalRun = runScope.Count; + var doneInRun = runScope.Count(IsDoneRun); + var hasActive = runScope.Any(static i => + string.Equals(i.Status, ConversionQueueStatus.Running, StringComparison.Ordinal) + || string.Equals(i.Status, ConversionQueueStatus.Copying, StringComparison.Ordinal) + || string.Equals(i.Status, ConversionQueueStatus.Replacing, StringComparison.Ordinal) + || string.Equals(i.Status, ConversionQueueStatus.Analyzing, StringComparison.Ordinal)); + var current = Math.Min(totalRun, Math.Max(1, doneInRun + (hasActive ? 1 : 0))); + ExecutionPhaseCaption = $"Конвертация файла {current} из {totalRun}..."; + } + } + + private static bool IsBusyForOverallProgressCap(ConversionQueueItem item) + { + return item.Status switch + { + ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing => + true, + ConversionQueueStatus.Analyzing => true, + _ => false, + }; + } + + private void ReapplySubtitleDefaultRuleToAnalyzedTasks() + { + if (IsExecutionRunning) + { + return; + } + + foreach (var item in QueueTasks) + { + if (item.MediaAnalysis is null) + { + continue; + } + + var subtitles = item.TaskOverride.TrackOverrides.Where(t => t.StreamKind == MediaStreamKind.Subtitle).ToList(); + if (subtitles.Count == 0) + { + continue; + } + + if (DisableSubtitleDefault) + { + foreach (var subtitle in subtitles) + { + subtitle.Default = false; + } + } + + var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, profile, item.TaskOverride, item.ExternalAudioFiles); + item.SetPlan(plan); + } + } +} diff --git a/EmbyToolbox/ViewModels/FileConversionSettingsViewModel.cs b/EmbyToolbox/ViewModels/FileConversionSettingsViewModel.cs new file mode 100644 index 0000000..24210be --- /dev/null +++ b/EmbyToolbox/ViewModels/FileConversionSettingsViewModel.cs @@ -0,0 +1,1501 @@ +using System.Collections.ObjectModel; +using System.Collections; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using EmbyToolbox.Models; +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +public sealed class FileConversionSettingsViewModel : INotifyPropertyChanged, ITrackPlanPreviewHost +{ + private readonly ConversionQueueItem _item; + private readonly ConversionTaskOverride _draft; + private readonly ConversionPlanService _planner; + private readonly IProfileSettingsProvider _profile; + private readonly TrackSettingsSnapshotService _snapshotService; + private readonly LoggingService _logging; + private readonly Action _saveAndCloseAction; + private string _planPreview = string.Empty; + private string _container = string.Empty; + private string _video = string.Empty; + private string _pixel = string.Empty; + private string _resolution = string.Empty; + private string _fps = string.Empty; + private string _videoBitrateMode = VideoBitratePolicy.Auto; + private string _videoBitrateCustomMbps = string.Empty; + private string _currentFileVideoBitrate = "Текущее: неизвестно"; + private string _bulkTrackType = "Все"; + private TrackActionKind? _bulkActionValue; + private string? _bulkBitrateValue; + private bool _isSyncingBulkFromSelection; + private bool _isBulkBitrateEnabled; + private string? _fileContainer; + private string? _fileVideo; + private string? _filePixel; + private string? _fileResolution; + private string? _fileFps; + private bool _isAutoAppliedFromSnapshot; + private string _snapshotStatusText = string.Empty; + private string _toastMessage = string.Empty; + private bool _isToastVisible; + private ToastKind _toastKind; + private DispatcherTimer? _toastHideTimer; + + private const double ToastDismissSecondsMin = 2.0; + private const double ToastDismissSecondsMax = 3.0; + + /// Порог длины текста (символы): дольше — показ до с. + private const int ToastLongMessageCharThreshold = 56; + + /// Строки для toast (кратко и заметно); подробности — в статусе внизу. + private const string SnapshotToastAppliedFull = + "Настройки предыдущего файла применены"; + + private const string SnapshotToastAppliedPartial = + "Частично применены настройки предыдущего файла"; + + /// Единый текст toast при любой неудаче автоприменения snapshot. + private const string SnapshotToastNotApplied = + "Настройки предыдущего файла не применены"; + + private const string SnapshotStatusStructureMismatch = + "Настройки предыдущего файла не применены: структура дорожек отличается"; + + public FileConversionSettingsViewModel( + ConversionQueueItem item, + ConversionPlanService planner, + IProfileSettingsProvider profile, + TrackSettingsSnapshotService snapshotService, + LoggingService logging, + ConversionFormOptions formOptions, + bool copyPreviousTrackSettings, + Action onSaveApplied, + Action onClose) + { + _item = item; + _planner = planner; + _profile = profile; + _snapshotService = snapshotService; + _logging = logging; + _draft = item.TaskOverride.Clone(); + _saveAndCloseAction = () => + { + if (_item.MediaAnalysis is null) + { + return; + } + + if (!ValidateBeforeSave(showMessage: true)) + { + return; + } + + var hasChangesFromCurrent = !OverridesEquivalent(_draft, _item.TaskOverride); + var isManual = IsDraftDifferentFromAutoPlan(); + _item.TaskOverride.CopyFrom(_draft); + var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var plan = _planner.Build(_item.MediaAnalysis, _item.Sidecars, prof, _item.TaskOverride, _item.ExternalAudioFiles); + _item.IsManuallyEdited = isManual; + _item.SetPlan(plan); + SaveCurrentSnapshot(); + onSaveApplied(_item, hasChangesFromCurrent); + onClose(); + }; + FormOptions = formOptions; + FilePath = item.FullPath; + ProfileName = item.Profile; + FillFileActuals(); + _container = _draft.TargetContainer; + _video = _draft.TargetVideo; + _pixel = _draft.TargetPixelFormat; + _resolution = _draft.TargetResolution; + _fps = _draft.TargetFps; + _videoBitrateMode = string.IsNullOrWhiteSpace(_draft.TargetVideoBitrateMode) + ? VideoBitratePolicy.Auto + : _draft.TargetVideoBitrateMode; + _videoBitrateCustomMbps = _draft.TargetVideoBitrateMbps?.ToString("0.###", CultureInfo.InvariantCulture) ?? string.Empty; + _planPreview = item.LastPlan?.ShortSummary ?? item.PlanSummary; + RebuildRowsFromDraft(); + if (copyPreviousTrackSettings) + { + TryApplyPreviousSnapshot(); + } + else + { + OpenWithAutoPlanOnly(); + } + + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + + SaveCommand = new RelayCommand(() => _saveAndCloseAction(), () => _item.MediaAnalysis is not null); + SaveAndCloseCommand = new RelayCommand( + ExecuteSaveAndCloseFromHotkey, + CanExecuteSaveAndCloseFromHotkey); + + CancelCommand = new RelayCommand(onClose); + UndoAutoApplyCommand = new RelayCommand(ExecuteUndoAutoApply, () => IsAutoAppliedFromSnapshot); + RemoveForeignTracksCommand = new RelayCommand(RemoveForeignTracks); + MarkForeignTracksForRemovalCommand = RemoveForeignTracksCommand; + SetSelectedTracksRemoveCommand = new RelayCommand(SetSelectedTracksRemove, CanSetSelectedTracksRemove); + OnSelectionChangedCommand = new RelayCommand(OnSelectionChanged); + ContextSetActionCommand = new RelayCommand(ApplyContextAction, CanApplyContextAction); + ContextSetBitrateCommand = new RelayCommand(ApplyContextBitrate, CanApplyContextBitrate); + PlayFileCommand = new RelayCommand(ExecutePlayFile, CanExecutePlayFile); + CloseToastCommand = new RelayCommand(ExecuteCloseToast); + } + + public string BulkTrackType + { + get => _bulkTrackType; + set + { + if (_bulkTrackType == value) + { + return; + } + + _bulkTrackType = value; + OnPropertyChanged(); + SyncBulkControlsFromSelection(); + } + } + + public TrackActionKind? BulkActionValue + { + get => _bulkActionValue; + set + { + if (_bulkActionValue == value) + { + return; + } + + _bulkActionValue = value; + OnPropertyChanged(); + if (!_isSyncingBulkFromSelection && value is { } action) + { + ApplyBulkActionOnChange(action); + } + } + } + + public string? BulkBitrateValue + { + get => _bulkBitrateValue; + set + { + if (_bulkBitrateValue == value) + { + return; + } + + _bulkBitrateValue = value; + OnPropertyChanged(); + if (!_isSyncingBulkFromSelection && !string.IsNullOrWhiteSpace(value)) + { + ApplyBulkBitrateOnChange(value); + } + } + } + + public ConversionFormOptions FormOptions { get; } + public string FilePath { get; } + public string ProfileName { get; } + public ObservableCollection TrackRows { get; } = new(); + public ObservableCollection SelectedTracks { get; } = new(); + public string? CurrentFileContainer => _fileContainer; + public string? CurrentFileVideo => _fileVideo; + public string? CurrentFilePixel => _filePixel; + public string? CurrentFileResolution => _fileResolution; + public string? CurrentFileFps => _fileFps; + public string CurrentFileVideoBitrate => _currentFileVideoBitrate; + public IReadOnlyList VideoBitrateOptions => FormOptions.VideoBitrateModeOptions; + public bool IsVideoBitrateCustomVisible => string.Equals(TargetVideoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal); + public ICommand SaveCommand { get; } + public RelayCommand SaveAndCloseCommand { get; } + public ICommand CancelCommand { get; } + public RelayCommand UndoAutoApplyCommand { get; } + public ICommand RemoveForeignTracksCommand { get; } + public ICommand MarkForeignTracksForRemovalCommand { get; } + public RelayCommand SetSelectedTracksRemoveCommand { get; } + public ICommand OnSelectionChangedCommand { get; } + public RelayCommand ContextSetActionCommand { get; } + public RelayCommand ContextSetBitrateCommand { get; } + public RelayCommand PlayFileCommand { get; } + + public RelayCommand CloseToastCommand { get; } + + /// Подсказка для кнопки воспроизведения (файл существует / нет). + public string PlayFileToolTip => + CanExecutePlayFile(FilePath) ? "Воспроизвести файл" : "Файл не найден"; + + public IReadOnlyList BulkTrackTypeOptions { get; } = ["Все", "Видео", "Аудио", "Субтитры", "Attachments"]; + public IReadOnlyList BulkActionOptions { get; } = [TrackActionKind.Keep, TrackActionKind.Convert, TrackActionKind.Remove, TrackActionKind.Add]; + public IReadOnlyList BulkBitrateOptions => FormOptions.AudioBitrateKbps; + public bool IsBulkBitrateEnabled + { + get => _isBulkBitrateEnabled; + private set + { + if (_isBulkBitrateEnabled == value) + { + return; + } + + _isBulkBitrateEnabled = value; + OnPropertyChanged(); + } + } + + public bool IsAutoAppliedFromSnapshot + { + get => _isAutoAppliedFromSnapshot; + private set + { + if (_isAutoAppliedFromSnapshot == value) + { + return; + } + + _isAutoAppliedFromSnapshot = value; + OnPropertyChanged(); + UndoAutoApplyCommand?.RaiseCanExecuteChanged(); + } + } + + public string SnapshotStatusText + { + get => _snapshotStatusText; + private set + { + if (_snapshotStatusText == value) + { + return; + } + + _snapshotStatusText = value; + OnPropertyChanged(); + } + } + + public string ToastMessage + { + get => _toastMessage; + private set + { + if (_toastMessage == value) + { + return; + } + + _toastMessage = value; + OnPropertyChanged(); + } + } + + public bool IsToastVisible + { + get => _isToastVisible; + private set + { + if (_isToastVisible == value) + { + return; + } + + _isToastVisible = value; + OnPropertyChanged(); + } + } + + public ToastKind ToastKind + { + get => _toastKind; + private set + { + if (_toastKind == value) + { + return; + } + + _toastKind = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ToastIconGlyph)); + } + } + + public string ToastIconGlyph => + ToastKind switch + { + ToastKind.Success => "\uE73E", + ToastKind.Warning => "\uE814", + ToastKind.Error => "\uEA39", + _ => "\uE946" + }; + + public void ShowToast(string message, ToastKind kind) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + StopToastHideTimer(); + + var trimmed = message.Trim(); + ToastMessage = trimmed; + ToastKind = kind; + IsToastVisible = true; + + var sec = ResolveToastDismissSeconds(trimmed); + + var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + _toastHideTimer = new DispatcherTimer( + TimeSpan.FromSeconds(sec), + DispatcherPriority.Background, + OnToastHideTick, + dispatcher); + } + + private static double ResolveToastDismissSeconds(string message) => + message.Length > ToastLongMessageCharThreshold + ? ToastDismissSecondsMax + : ToastDismissSecondsMin + + (ToastDismissSecondsMax - ToastDismissSecondsMin) + * (message.Length / (double)ToastLongMessageCharThreshold); + + private void OnToastHideTick(object? sender, EventArgs e) + { + HideToastInstant(); + } + + private void ExecuteCloseToast() + { + if (!IsToastVisible) + { + return; + } + + HideToastInstant(); + } + + private void HideToastInstant() + { + StopToastHideTimer(); + IsToastVisible = false; + ToastMessage = string.Empty; + } + + private void StopToastHideTimer() + { + if (_toastHideTimer is null) + { + return; + } + + _toastHideTimer.Stop(); + _toastHideTimer = null; + } + + public string PlanPreview + { + get => _planPreview; + private set + { + if (_planPreview == value) + { + return; + } + + _planPreview = value; + OnPropertyChanged(); + } + } + + public string TargetContainer + { + get => _container; + set + { + if (_container == value) + { + return; + } + + _container = value; + _draft.TargetContainer = value; + OnPropertyChanged(); + foreach (var row in TrackRows) + { + row.RefreshSubtitleDetails(_container); + } + + RecalculatePlanPreview(); + } + } + + public string TargetVideo + { + get => _video; + set + { + if (_video == value) + { + return; + } + + _video = value; + _draft.TargetVideo = value; + OnPropertyChanged(); + RecalculatePlanPreview(); + } + } + + public string TargetPixelFormat + { + get => _pixel; + set + { + if (_pixel == value) + { + return; + } + + _pixel = value; + _draft.TargetPixelFormat = value; + OnPropertyChanged(); + RecalculatePlanPreview(); + } + } + + public string TargetResolution + { + get => _resolution; + set + { + if (_resolution == value) + { + return; + } + + _resolution = value; + _draft.TargetResolution = value; + OnPropertyChanged(); + RecalculatePlanPreview(); + } + } + + public string TargetFps + { + get => _fps; + set + { + if (_fps == value) + { + return; + } + + _fps = value; + _draft.TargetFps = value; + OnPropertyChanged(); + RecalculatePlanPreview(); + } + } + + public string TargetVideoBitrateMode + { + get => _videoBitrateMode; + set + { + var next = string.IsNullOrWhiteSpace(value) ? VideoBitratePolicy.Auto : value.Trim(); + if (_videoBitrateMode == next) + { + return; + } + + _videoBitrateMode = next; + _draft.TargetVideoBitrateMode = next; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsVideoBitrateCustomVisible)); + RecalculatePlanPreview(); + } + } + + public string VideoBitrateCustomMbps + { + get => _videoBitrateCustomMbps; + set + { + if (_videoBitrateCustomMbps == value) + { + return; + } + + _videoBitrateCustomMbps = value; + OnPropertyChanged(); + if (TryParseCustomVideoBitrate(value, out var mbps)) + { + _draft.TargetVideoBitrateMbps = mbps; + } + else + { + _draft.TargetVideoBitrateMbps = null; + } + + RecalculatePlanPreview(); + } + } + + public void RecalculatePlanPreview() + { + if (_item.MediaAnalysis is not { } m) + { + PlanPreview = "—"; + return; + } + + _draft.TargetContainer = _container; + _draft.TargetVideo = _video; + _draft.TargetPixelFormat = _pixel; + _draft.TargetResolution = _resolution; + _draft.TargetFps = _fps; + _draft.TargetVideoBitrateMode = _videoBitrateMode; + _draft.TargetVideoBitrateMbps = string.Equals(_videoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal) + && TryParseCustomVideoBitrate(_videoBitrateCustomMbps, out var mbps) + ? mbps + : null; + ValidateDefaultConflicts(); + var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var plan = _planner.Build(m, _item.Sidecars, prof, _draft, _item.ExternalAudioFiles); + PlanPreview = plan.ShortSummary; + } + + public void OnTrackDefaultEnabled(TrackSettingsRowViewModel row) + { + if (row.DataModel.StreamKind is not (MediaStreamKind.Audio or MediaStreamKind.Subtitle)) + { + return; + } + + foreach (var r in TrackRows) + { + if (ReferenceEquals(r, row)) + { + continue; + } + + if (r.DataModel.StreamKind == row.DataModel.StreamKind) + { + r.Default = false; + } + } + } + + public void ValidateDefaultConflicts() + { + var audioDefaults = TrackRows + .Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio && r.Action != TrackActionKind.Remove && r.Default is true) + .ToList(); + var subDefaults = TrackRows + .Where(r => r.DataModel.StreamKind == MediaStreamKind.Subtitle && r.Action != TrackActionKind.Remove && r.Default is true) + .ToList(); + + foreach (var r in TrackRows) + { + r.HasDefaultConflict = false; + } + + if (audioDefaults.Count > 1) + { + foreach (var r in audioDefaults) + { + r.HasDefaultConflict = true; + } + } + + if (subDefaults.Count > 1) + { + foreach (var r in subDefaults) + { + r.HasDefaultConflict = true; + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private bool CanExecuteSaveAndCloseFromHotkey() + { + if (_item.MediaAnalysis is null) + { + return false; + } + + if (Keyboard.FocusedElement is System.Windows.Controls.ComboBox cb && cb.IsDropDownOpen) + { + return false; + } + + return true; + } + + private void ExecuteSaveAndCloseFromHotkey() + { + _logging.Debug("Настройки дорожек сохранены через Ctrl+Enter", "conversion.keyboard"); + _saveAndCloseAction(); + } + + private void OnPropertyChanged([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + private void FillFileActuals() + { + var m = _item.MediaAnalysis; + if (m is null) + { + return; + } + + _fileContainer = NormalizeContainerForUi(m.ContainerFormat ?? m.FormatName, _item.FullPath); + _currentFileVideoBitrate = VideoBitratePolicy.FormatCurrentSource(m.SourceVideoBitrateBps); + if (m.PrimaryVideo is { } v) + { + _fileVideo = v.CodecName; + _filePixel = v.PixelFormat; + if (v.Width is { } w && v.Height is { } h) + { + _fileResolution = $"{w}x{h}"; + } + + if (v.FrameRate is { } f) + { + _fileFps = f.ToString("0.###", CultureInfo.InvariantCulture); + } + } + } + + private void RebuildRowsFromDraft() + { + TrackRows.Clear(); + var media = _item.MediaAnalysis; + var n = 1; + foreach (var t in _draft.TrackOverrides) + { + MediaStreamInfo? em = t.Source == SourceKind.Embedded && media is not null + ? media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex) + : null; + TrackRows.Add(new TrackSettingsRowViewModel(this, t, n, em, _container)); + n++; + } + } + + /// Без snapshot: черновик уже из сохранённого состояния файла и автоплана анализа; только сброс UI статуса snapshot. + private void OpenWithAutoPlanOnly() + { + IsAutoAppliedFromSnapshot = false; + SnapshotStatusText = string.Empty; + HideToastInstant(); + } + + private void TryApplyPreviousSnapshot() + { + var current = BuildSnapshotItemsFromDraft(); + var undRisk = TrackSettingsSnapshotService.TracksHaveRiskyMultipleUndTracks(current); + var applyResult = _snapshotService.TryApplySnapshot(current, _item.FullPath, _item.SnapshotScopeBatchRoot); + if (applyResult.Reason == SnapshotApplyReason.NoSnapshot) + { + return; + } + + if (applyResult.Reason == SnapshotApplyReason.ScopeMismatch) + { + SnapshotStatusText = "Настройки из другого каталога или пакета — не применены."; + ShowToast(SnapshotToastNotApplied, ToastKind.Error); + return; + } + + if (!applyResult.AppliedAny || applyResult.TrackResults is null) + { + SnapshotStatusText = SnapshotStatusStructureMismatch; + ShowToast(SnapshotToastNotApplied, ToastKind.Error); + return; + } + + var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var appliedRows = 0; + foreach (var row in TrackRows) + { + var mr = applyResult.TrackResults.FirstOrDefault(r => r.CurrentOrder == row.IndexDisplay); + if (mr is not { IsMatched: true, SourceItem: { } src }) + { + continue; + } + + if (!TryApplyMatchedSnapshotToRow(row, src, prof)) + { + continue; + } + + row.AudioBitrateKbps = src.Bitrate; + row.Default = src.Default; + if (src.SnapshotTitleWasUserEdited && !string.IsNullOrWhiteSpace(src.Title)) + { + row.Title = src.Title.Trim(); + } + + row.Language = string.IsNullOrWhiteSpace(src.Language) ? null : src.Language.Trim(); + appliedRows++; + } + + if (appliedRows == 0) + { + SnapshotStatusText = SnapshotStatusStructureMismatch; + ShowToast(SnapshotToastNotApplied, ToastKind.Error); + return; + } + + IsAutoAppliedFromSnapshot = true; + var undSuffix = undRisk + ? " Snapshot применён к und-дорожкам по порядку внутри группы (риск перепутать)." + : string.Empty; + var totalRows = TrackRows.Count; + + ToastKind toastKind; + string toastCopy; + string statusLinePartial; + switch (applyResult.Degree) + { + case SnapshotApplyDegree.Full: + toastKind = ToastKind.Success; + toastCopy = SnapshotToastAppliedFull; + statusLinePartial = "Применены настройки предыдущего файла"; + break; + case SnapshotApplyDegree.Partial: + toastKind = ToastKind.Warning; + toastCopy = SnapshotToastAppliedPartial; + statusLinePartial = "Частично применены настройки предыдущего файла"; + var matchedInKeys = applyResult.TrackResults.Count(r => r.IsMatched); + _logging.Info($"Совпало {matchedInKeys} из {totalRows} дорожек", "conversion.snapshot"); + break; + default: + toastKind = ToastKind.Error; + toastCopy = SnapshotToastNotApplied; + statusLinePartial = SnapshotStatusStructureMismatch; + break; + } + + SnapshotStatusText = statusLinePartial + undSuffix; + ShowToast(toastCopy, toastKind); + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + } + + private bool TryApplyMatchedSnapshotToRow( + TrackSettingsRowViewModel row, + TrackSettingsSnapshotItem src, + ConversionProfileSettingsEntry profile) + { + var entry = row.DataModel; + if (entry.StreamKind != src.StreamKind || entry.Source != src.Source) + { + return false; + } + + var action = src.Action; + + if (action == TrackActionKind.Add && entry.Source != SourceKind.External) + { + return false; + } + + if (action == TrackActionKind.Remove && entry.StreamKind == MediaStreamKind.Video) + { + return false; + } + + if (action == TrackActionKind.Convert + && entry.Source == SourceKind.Embedded + && _item.MediaAnalysis is { } media) + { + if (entry.StreamKind == MediaStreamKind.Audio && entry.StreamIndex >= 0) + { + var st = media.AllStreams.FirstOrDefault( + s => s.Index == entry.StreamIndex && s.Kind == MediaStreamKind.Audio); + if (st is not null && ConversionPlanService.EmbeddedAudioMatchesProfile(st.CodecName, profile)) + { + action = TrackActionKind.Keep; + } + } + else if (entry.StreamKind == MediaStreamKind.Video && entry.StreamIndex >= 0) + { + var vst = media.AllStreams.FirstOrDefault( + s => s.Index == entry.StreamIndex && s.Kind == MediaStreamKind.Video); + if (vst is not null) + { + var targetV = string.IsNullOrWhiteSpace(_draft.TargetVideo) ? profile.Video : _draft.TargetVideo; + if (ConversionPlanService.VideoCodecMatchesTarget(vst.CodecName, targetV)) + { + action = TrackActionKind.Keep; + } + } + } + } + + if (!row.ValidActions.Contains(action)) + { + return false; + } + + row.Action = action; + return true; + } + + private void ExecuteUndoAutoApply() + { + if (_item.MediaAnalysis is null) + { + return; + } + + var profile = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var reset = new ConversionTaskOverride(); + TrackOverrideSeeder.EnsureDefaults( + reset, + _item.MediaAnalysis, + _item.Sidecars, + profile, + externalAudio: _item.ExternalAudioFiles, + videoPath: _item.FullPath); + _draft.CopyFrom(reset); + + RebuildRowsFromDraft(); + IsAutoAppliedFromSnapshot = false; + SnapshotStatusText = string.Empty; + HideToastInstant(); + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + } + + private void SaveCurrentSnapshot() + { + var items = BuildSnapshotItemsFromDraft(); + _snapshotService.SaveSnapshot(_item.FullPath, items, _item.SnapshotScopeBatchRoot); + } + + private IReadOnlyList BuildSnapshotItemsFromDraft() + { + var list = new List(_draft.TrackOverrides.Count); + var media = _item.MediaAnalysis; + var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var order = 1; + foreach (var t in _draft.TrackOverrides) + { + var codec = ResolveCodecForSnapshot(t, media); + list.Add(new TrackSettingsSnapshotItem + { + Order = order, + StreamKind = t.StreamKind, + Source = t.Source, + Codec = codec, + Language = (t.Language ?? string.Empty).Trim(), + Title = (t.Title ?? string.Empty).Trim(), + Action = t.Action, + Bitrate = t.AudioBitrateKbps, + Default = t.Default, + TargetCodec = t.StreamKind switch + { + MediaStreamKind.Video => _draft.TargetVideo, + MediaStreamKind.Audio => prof.Audio, + _ => null + }, + SnapshotTitleWasUserEdited = IsSnapshotTitleEditedByUser(t, media) + }); + order++; + } + + return list; + } + + private bool IsSnapshotTitleEditedByUser(TrackOverrideEntry t, MediaAnalysisResult? media) + { + var canonical = CanonicalTitleFromSource(t, media); + var a = TrackSettingsSnapshotService.NormalizeTitleFingerprint(t.Title); + var b = TrackSettingsSnapshotService.NormalizeTitleFingerprint(canonical); + return !string.Equals(a, b, StringComparison.Ordinal); + } + + private string? CanonicalTitleFromSource(TrackOverrideEntry t, MediaAnalysisResult? media) + { + if (media is not null && t.Source == SourceKind.Embedded && t.StreamIndex >= 0) + { + var s = media.AllStreams.FirstOrDefault(x => x.Index == t.StreamIndex); + return s?.Title; + } + + if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalPath)) + { + return TrackOverrideSeeder.ExternalAudioCanonicalTitleFromEntry(_draft.TrackOverrides, t, _item.FullPath); + } + + if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Subtitle && !string.IsNullOrWhiteSpace(t.ExternalPath)) + { + return TrackOverrideSeeder.ExternalSubtitleCanonicalTitle(_draft.TrackOverrides, t, _item.FullPath); + } + + if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Attachment && !string.IsNullOrWhiteSpace(t.ExternalPath)) + { + return Path.GetFileName(t.ExternalPath); + } + + if (t.Source == SourceKind.External && !string.IsNullOrWhiteSpace(t.ExternalPath)) + { + return Path.GetFileNameWithoutExtension(Path.GetFileName(t.ExternalPath)); + } + + return string.Empty; + } + + private static string ResolveCodecForSnapshot(TrackOverrideEntry t, MediaAnalysisResult? media) + { + if (t.Source == SourceKind.Embedded && media is not null && t.StreamIndex >= 0) + { + var src = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex); + if (!string.IsNullOrWhiteSpace(src?.CodecName)) + { + return src!.CodecName; + } + } + + if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalStreamCodec)) + { + return t.ExternalStreamCodec.Trim(); + } + + if (t.Source == SourceKind.External && !string.IsNullOrWhiteSpace(t.ExternalPath)) + { + var ext = Path.GetExtension(t.ExternalPath); + if (!string.IsNullOrWhiteSpace(ext)) + { + return ext.TrimStart('.').ToLowerInvariant(); + } + } + + return string.Empty; + } + + private void RemoveForeignTracks() + { + foreach (var row in TrackRows) + { + var t = row.DataModel; + if (t.Source != SourceKind.Embedded) + { + continue; + } + + if (t.StreamKind is not (MediaStreamKind.Audio or MediaStreamKind.Subtitle)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(t.Language)) + { + continue; + } + + var lang = t.Language!.Trim().ToLowerInvariant(); + if (lang is "und" or "unknown" or "?") + { + continue; + } + + if (lang is "rus" or "ru") + { + continue; + } + + row.Action = TrackActionKind.Remove; + } + + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + } + + private bool ValidateBeforeSave(bool showMessage) + { + ValidateDefaultConflicts(); + var audioConflicts = TrackRows.Count(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Audio); + var subConflicts = TrackRows.Count(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Subtitle); + if (audioConflicts > 0 || subConflicts > 0) + { + if (showMessage) + { + MessageBox.Show( + "Ошибка: для Audio и Subtitle может быть только одна дорожка Default (исключая Remove).", + "Валидация", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + + return false; + } + + if (string.Equals(_videoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal) + && !TryParseCustomVideoBitrate(_videoBitrateCustomMbps, out _)) + { + if (showMessage) + { + MessageBox.Show( + "Поле Custom bitrate, Mbps должно быть числом больше 0.", + "Валидация", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + + return false; + } + + return true; + } + + private void OnSelectionChanged(object? parameter) + { + SelectedTracks.Clear(); + if (parameter is IList list) + { + foreach (var r in list.OfType()) + { + SelectedTracks.Add(r); + } + } + + SyncBulkControlsFromSelection(); + ContextSetActionCommand.RaiseCanExecuteChanged(); + ContextSetBitrateCommand.RaiseCanExecuteChanged(); + SetSelectedTracksRemoveCommand.RaiseCanExecuteChanged(); + } + + private bool CanSetSelectedTracksRemove(object? parameter) + { + if (parameter is not IList list || list.Count == 0) + { + return false; + } + + return list.OfType().Any(r => r.ValidActions.Contains(TrackActionKind.Remove)); + } + + private void SetSelectedTracksRemove(object? parameter) + { + if (parameter is not IList list || list.Count == 0) + { + return; + } + + var rows = list.OfType().ToList(); + var changed = false; + foreach (var row in rows) + { + if (!row.ValidActions.Contains(TrackActionKind.Remove)) + { + continue; + } + + row.Action = TrackActionKind.Remove; + changed = true; + } + + if (changed) + { + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + SyncBulkControlsFromSelection(); + } + } + + private bool CanApplyContextAction(object? parameter) + { + if (parameter is not TrackActionKind action) + { + return false; + } + + var rows = SelectedTracks.ToList(); + if (rows.Count == 0) + { + return false; + } + + return rows.Any(r => CanApplyActionToRow(r, action)); + } + + private void ApplyContextAction(object? parameter) + { + if (parameter is not TrackActionKind action) + { + return; + } + + var rows = SelectedTracks.ToList(); + var changed = false; + foreach (var row in rows) + { + if (!CanApplyActionToRow(row, action)) + { + continue; + } + + row.Action = action; + changed = true; + } + + if (changed) + { + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + SyncBulkControlsFromSelection(); + } + } + + private bool CanApplyContextBitrate(object? parameter) + { + if (parameter is not string br || string.IsNullOrWhiteSpace(br)) + { + return false; + } + + return SelectedTracks.Any(r => r.DataModel.StreamKind == MediaStreamKind.Audio); + } + + private void ApplyContextBitrate(object? parameter) + { + if (parameter is not string br || string.IsNullOrWhiteSpace(br)) + { + return; + } + + var rows = SelectedTracks.ToList(); + var changed = false; + foreach (var row in rows.Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio)) + { + row.AudioBitrateKbps = br; + changed = true; + } + + if (changed) + { + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + SyncBulkControlsFromSelection(); + } + } + + private static bool CanApplyActionToRow(TrackSettingsRowViewModel row, TrackActionKind action) + { + if (action == TrackActionKind.Add && row.DataModel.Source != SourceKind.External) + { + return false; + } + + if (action == TrackActionKind.Convert && row.DataModel.StreamKind is MediaStreamKind.Subtitle or MediaStreamKind.Attachment) + { + return false; + } + + return row.ValidActions.Contains(action); + } + + private void ApplyBulkActionOnChange(TrackActionKind action) + { + var rows = GetBulkTargetRows().ToList(); + if (rows.Count == 0) + { + return; + } + + foreach (var row in rows) + { + if (row.ValidActions.Contains(action)) + { + row.Action = action; + } + } + + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + SyncBulkControlsFromSelection(); + } + + private void ApplyBulkBitrateOnChange(string bitrate) + { + var changed = false; + foreach (var row in GetBulkTargetRows().Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio)) + { + row.AudioBitrateKbps = bitrate; + changed = true; + } + + if (changed) + { + ValidateDefaultConflicts(); + RecalculatePlanPreview(); + SyncBulkControlsFromSelection(); + } + } + + private IEnumerable GetBulkTargetRows() => + SelectedTracks.Where(IsTrackTypeMatch); + + private bool IsTrackTypeMatch(TrackSettingsRowViewModel row) => + BulkTrackType switch + { + "Видео" => row.DataModel.StreamKind == MediaStreamKind.Video, + "Аудио" => row.DataModel.StreamKind == MediaStreamKind.Audio, + "Субтитры" => row.DataModel.StreamKind == MediaStreamKind.Subtitle, + "Attachments" => row.DataModel.StreamKind == MediaStreamKind.Attachment, + _ => true + }; + + private void SyncBulkControlsFromSelection() + { + _isSyncingBulkFromSelection = true; + try + { + var rows = GetBulkTargetRows().ToList(); + if (rows.Count == 0) + { + BulkActionValue = null; + BulkBitrateValue = null; + IsBulkBitrateEnabled = false; + return; + } + + var firstAction = rows[0].Action; + BulkActionValue = rows.All(r => r.Action == firstAction) ? firstAction : null; + + var audioRows = rows.Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio).ToList(); + IsBulkBitrateEnabled = audioRows.Count > 0; + if (audioRows.Count == 0) + { + BulkBitrateValue = null; + return; + } + + var firstBr = audioRows[0].AudioBitrateKbps; + BulkBitrateValue = audioRows.All(r => string.Equals(r.AudioBitrateKbps, firstBr, StringComparison.Ordinal)) ? firstBr : null; + } + finally + { + _isSyncingBulkFromSelection = false; + } + + ContextSetActionCommand.RaiseCanExecuteChanged(); + ContextSetBitrateCommand.RaiseCanExecuteChanged(); + } + + private bool IsDraftDifferentFromAutoPlan() + { + if (_item.MediaAnalysis is null) + { + return false; + } + + var profile = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; + var auto = new ConversionTaskOverride(); + TrackOverrideSeeder.EnsureDefaults( + auto, + _item.MediaAnalysis, + _item.Sidecars, + profile, + externalAudio: _item.ExternalAudioFiles, + videoPath: _item.FullPath); + return !OverridesEquivalent(_draft, auto); + } + + 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 bool TryParseCustomVideoBitrate(string? raw, out double mbps) + { + mbps = 0; + if (string.IsNullOrWhiteSpace(raw)) + { + return false; + } + + return double.TryParse(raw.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out mbps) + && mbps > 0; + } + + private bool CanExecutePlayFile(object? parameter) + { + if (!TryResolvePlayFullPath(parameter, out var path)) + { + return false; + } + + return File.Exists(path); + } + + private void ExecutePlayFile(object? parameter) + { + if (!TryResolvePlayFullPath(parameter, out var path)) + { + return; + } + + if (!File.Exists(path)) + { + return; + } + + try + { + Process.Start( + new ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + _logging.Error($"Не удалось воспроизвести файл «{path}»: {ex.Message}", "conversion.fileSettings", ex); + } + } + + private bool TryResolvePlayFullPath(object? parameter, out string path) + { + path = string.Empty; + var raw = parameter as string; + if (string.IsNullOrWhiteSpace(raw)) + { + raw = FilePath; + } + + if (string.IsNullOrWhiteSpace(raw)) + { + return false; + } + + raw = raw.Trim(); + + try + { + path = Path.GetFullPath(raw); + return true; + } + catch + { + return false; + } + } + + private static string NormalizeContainerForUi(string? rawFormatName, string filePath) + { + if (string.IsNullOrWhiteSpace(rawFormatName)) + { + return string.Empty; + } + + var raw = rawFormatName.Trim(); + var ext = Path.GetExtension(filePath).Trim().ToLowerInvariant(); + + if (raw.Contains("matroska", StringComparison.OrdinalIgnoreCase) || raw.Contains("webm", StringComparison.OrdinalIgnoreCase)) + { + return ext switch + { + ".webm" => "WebM", + ".mkv" => "MKV", + _ => "MKV/WebM" + }; + } + + if (raw.Contains("mp4", StringComparison.OrdinalIgnoreCase) || raw.Contains("mov", StringComparison.OrdinalIgnoreCase)) + { + return ext switch + { + ".mov" => "MOV", + ".mp4" or ".m4v" => "MP4", + _ => "MP4/MOV" + }; + } + + if (raw.Contains("avi", StringComparison.OrdinalIgnoreCase)) + { + return "AVI"; + } + + if (raw.Contains("mpegts", StringComparison.OrdinalIgnoreCase)) + { + return "TS"; + } + + if (raw.Contains("mpeg", StringComparison.OrdinalIgnoreCase)) + { + return "MPEG"; + } + + return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? raw; + } +} diff --git a/EmbyToolbox/ViewModels/JsonTreeNodeViewModel.cs b/EmbyToolbox/ViewModels/JsonTreeNodeViewModel.cs new file mode 100644 index 0000000..63e7b3c --- /dev/null +++ b/EmbyToolbox/ViewModels/JsonTreeNodeViewModel.cs @@ -0,0 +1,50 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace EmbyToolbox.ViewModels; + +public sealed class JsonTreeNodeViewModel : INotifyPropertyChanged +{ + private bool _isExpanded; + + public JsonTreeNodeViewModel(string name, string value, string subtreeJson, JsonTreeNodeViewModel? parent = null) + { + Name = name; + Value = value; + SubtreeJson = subtreeJson; + Parent = parent; + } + + public string Name { get; } + + public string Value { get; } + + public string SubtreeJson { get; } + + public JsonTreeNodeViewModel? Parent { get; } + + public ObservableCollection Children { get; } = new(); + + public bool IsExpanded + { + get => _isExpanded; + set + { + if (_isExpanded == value) + { + return; + } + + _isExpanded = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/EmbyToolbox/ViewModels/LogEntryViewModel.cs b/EmbyToolbox/ViewModels/LogEntryViewModel.cs new file mode 100644 index 0000000..0cf2e68 --- /dev/null +++ b/EmbyToolbox/ViewModels/LogEntryViewModel.cs @@ -0,0 +1,18 @@ +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +public sealed class LogEntryViewModel +{ + public required DateTime Timestamp { get; init; } + + public required LogLevel Level { get; init; } + + public required string LevelText { get; init; } + + public required string Module { get; init; } + + public required string Message { get; init; } + + public string DisplayText => $"[{Timestamp:HH:mm:ss}] {LevelText.ToLowerInvariant()}: {Message}"; +} diff --git a/EmbyToolbox/ViewModels/LogsViewModel.cs b/EmbyToolbox/ViewModels/LogsViewModel.cs new file mode 100644 index 0000000..64c6705 --- /dev/null +++ b/EmbyToolbox/ViewModels/LogsViewModel.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +/// UI вкладки «Логи». Поток записей — (тот же , без дублирования). +public sealed class LogsViewModel : INotifyPropertyChanged +{ + private readonly LoggingService _logging; + private int _scrollPulse; + + public LogsViewModel(LoggingService logging) + { + _logging = logging; + RefreshLogViewCommand = new RelayCommand(ExecuteRefreshScroll); + OpenLogsFolderCommand = new RelayCommand(ExecuteOpenLogsFolder); + ClearUiLogCommand = new RelayCommand(ExecuteClearUiLog, () => UiEntries.Count > 0); + } + + /// Инкремент для умного принудительного скролла вниз (кнопка «Обновить»). + public int ScrollPulse + { + get => _scrollPulse; + private set + { + if (_scrollPulse == value) + { + return; + } + + _scrollPulse = value; + OnPropertyChanged(); + } + } + + /// Тот же , что и у главного окна. + public ObservableCollection UiEntries => _logging.UiEntries; + + public RelayCommand RefreshLogViewCommand { get; } + + public RelayCommand OpenLogsFolderCommand { get; } + + public RelayCommand ClearUiLogCommand { get; } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void ExecuteRefreshScroll() => ScrollPulse++; + + private void ExecuteOpenLogsFolder() + { + Directory.CreateDirectory(_logging.LogsDirectory); + Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = _logging.LogsDirectory, + UseShellExecute = true + }); + _logging.Info("открыта папка логов", "ui.log"); + } + + private void ExecuteClearUiLog() + { + _logging.ClearUi(); + _logging.Info("UI-лог очищен", "ui.log"); + } + + public void RaiseClearCommandState() => + ClearUiLogCommand.RaiseCanExecuteChanged(); + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/EmbyToolbox/ViewModels/MainWindowViewModel.cs b/EmbyToolbox/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..040630b --- /dev/null +++ b/EmbyToolbox/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,892 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Shell; +using System.Windows.Threading; +using EmbyToolbox.Models; +using EmbyToolbox.Services; +using Microsoft.Win32; + +namespace EmbyToolbox.ViewModels; + +public sealed class MainWindowViewModel : INotifyPropertyChanged +{ + private readonly AppSettingsService _settingsService; + private readonly LoggingService _logging; + private readonly RecentPathService _recentPathService; + private readonly NotificationService _notificationService; + + private AppSettings _loadedSettings; + private string _processingTempDirectory = string.Empty; + private string _minimumFileLogLevel = LogLevel.Info.ToString(); + private string _hardwareAcceleration = HardwareAccelerationMode.Auto; + private bool _notifyCompletionSoundAfterQueue = true; + private bool _notifyWindowsToastAfterQueue = true; + private int _selectedTabIndex; + private string _appStatusText = "Готово"; + private double _taskbarProgressValue; + private TaskbarItemProgressState _taskbarProgressState = TaskbarItemProgressState.None; + + private bool _wasConversionExecuting; + private bool _wasMergeRunning; + private bool _wasTrackExtractionBusy; + private bool _queueEndedWithConversionErrors; + private bool _completionFlashActive; + private DispatcherTimer? _completionFlashTimer; + + private double _longOperationProgressPercent; + private string _longOperationProgressText = string.Empty; + private bool _isLongOperationRunning; + private bool _showLongOperationIdlePlaceholder = true; + + public MainWindowViewModel() + { + _settingsService = new AppSettingsService(); + _logging = new LoggingService(); + + _recentPathService = new RecentPathService(_settingsService); + _loadedSettings = _settingsService.Load(); + _recentPathService.HydrateFrom(_loadedSettings); + _processingTempDirectory = _loadedSettings.ProcessingTempDirectory; + _minimumFileLogLevel = _loadedSettings.MinimumFileLogLevel; + _hardwareAcceleration = _loadedSettings.HardwareAcceleration; + _notifyCompletionSoundAfterQueue = _loadedSettings.NotifyCompletionSoundAfterQueue; + _notifyWindowsToastAfterQueue = _loadedSettings.NotifyWindowsToastAfterQueue; + LoadConversionProfiles(_loadedSettings.ConversionProfiles); + ApplyLogLevelToService(); + + SeriesRenamer = new SeriesRenamerViewModel(new SeriesRenamerService(), _logging, _recentPathService); + IProfileSettingsProvider profileProvider = new ProfileSettingsProvider( + name => ConversionProfiles.FirstOrDefault( + p => string.Equals(p.Profile, name, StringComparison.OrdinalIgnoreCase))?.ToSettingsEntry()); + var planService = new ConversionPlanService(); + var sidecar = new SidecarDiscoveryService(_logging); + var ff = new FfprobeService(); + var trackSnapshotService = new TrackSettingsSnapshotService(_logging); + var exec = new ConversionExecutionService( + _logging, + new FfmpegCommandBuilder(), + new FfmpegService(), + ff, + new FfmpegEncoderDiscoveryService(), + () => HardwareAcceleration, + new SafeFileReplaceService(), + new ExternalFileCleanupService()); + _notificationService = new NotificationService( + _logging, + () => NotifyCompletionSoundAfterQueue, + () => NotifyWindowsToastAfterQueue, + Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher); + Conversion = new ConversionViewModel( + _logging, + new FileDiscoveryService(), + new QueueAnalysisService(ff, _logging, sidecar, planService, profileProvider), + planService, + profileProvider, + trackSnapshotService, + exec, + () => ProcessingTempDirectory, + _recentPathService, + _notificationService, + () => ConversionProfiles.Select(p => p.ToSettingsEntry()).ToList(), + () => ConversionProfiles.ToList(), + ApplyConversionProfilesFromQueueSetupDocument); + Conversion.CopyPreviousTrackSettings = _loadedSettings.CopyPreviousTrackSettings; + Conversion.DisableSubtitleDefault = _loadedSettings.DisableSubtitleDefault; + Conversion.SyncDefaultProfileFromList(ConversionProfiles.ToList()); + VideoInfo = new VideoInfoViewModel( + ff, + _logging, + _recentPathService, + sidecar, + new VideoInfoSummaryService()); + Merge = new MergeViewModel( + _logging, + new MergeService(_logging, ff, new ChapterBuilderService(), () => ProcessingTempDirectory), + _recentPathService); + TrackExtraction = new TrackExtractionViewModel(_logging, new TrackExtractionService(ff), _recentPathService); + Logs = new LogsViewModel(_logging); + + ChooseTempDirectoryCommand = new RelayCommand(ExecuteChooseTempDirectory); + SaveSettingsCommand = new RelayCommand(ExecuteSaveSettings); + CancelSettingsCommand = new RelayCommand(ExecuteCancelSettings); + CheckToolsCommand = new RelayCommand(ExecuteCheckTools); + AddConversionProfileCommand = new RelayCommand(ExecuteAddConversionProfile); + RemoveConversionProfileCommand = new RelayCommand(ExecuteRemoveConversionProfile, CanRemoveConversionProfile); + TestWindowsNotificationCommand = new RelayCommand(ExecuteTestWindowsNotification); + + _logging.UiEntries.CollectionChanged += OnUiEntriesCollectionChanged; + Conversion.PropertyChanged += OnConversionPropertyChanged; + Merge.PropertyChanged += OnMergePropertyChanged; + TrackExtraction.PropertyChanged += OnTrackExtractionPropertyChanged; + RefreshStatusBar(); + + _logging.Info("приложение запущено", "app"); + } + + public SeriesRenamerViewModel SeriesRenamer { get; } + + public VideoInfoViewModel VideoInfo { get; } + public ConversionViewModel Conversion { get; } + public MergeViewModel Merge { get; } + + public TrackExtractionViewModel TrackExtraction { get; } + + public LogsViewModel Logs { get; } + + public IReadOnlyList LogLevelOptions { get; } = new[] + { + LogLevel.Debug.ToString(), + LogLevel.Info.ToString(), + LogLevel.Warning.ToString(), + LogLevel.Error.ToString() + }; + public ObservableCollection ConversionProfiles { get; } = new(); + + private ConversionProfilePresetRow? _selectedConversionProfile; + + public ConversionProfilePresetRow? SelectedConversionProfile + { + get => _selectedConversionProfile; + set + { + if (_selectedConversionProfile == value) + { + return; + } + + _selectedConversionProfile = value; + OnPropertyChanged(); + RemoveConversionProfileCommand.RaiseCanExecuteChanged(); + } + } + public IReadOnlyList ConversionContainerOptions { get; } = ["MKV", "MP4", "MOV", "WEBM"]; + public IReadOnlyList ConversionVideoCodecOptions { get; } = ["H.264", "H.265", "AV1", "Copy"]; + public IReadOnlyList ConversionPixelFormatOptions { get; } = ["yuv420p", "yuv420p10le", "yuv422p", "yuv444p"]; + public IReadOnlyList ConversionResolutionOptions { get; } = ["Без изменений", "Максимум 2160p", "Максимум 1440p", "Максимум 1080p", "Максимум 720p"]; + public IReadOnlyList ConversionFpsOptions { get; } = ["Без изменений", "Максимум 60", "Максимум 30", "Максимум 25", "Максимум 24"]; + public IReadOnlyList ConversionAudioCodecOptions { get; } = ["AAC", "AC3", "EAC3", "Opus", "MP3", "FLAC", "Copy"]; + public IReadOnlyList ConversionBitrateOptions { get; } = ["96 kbps", "128 kbps", "160 kbps", "192 kbps", "256 kbps", "320 kbps"]; + public IReadOnlyList ConversionVideoBitrateOptions => VideoBitratePolicy.UiOptions; + public IReadOnlyList ConversionYesNoOptions { get; } = ["Да", "Нет"]; + public IReadOnlyList HardwareAccelerationOptions { get; } = + [ + HardwareAccelerationMode.Auto, + HardwareAccelerationMode.Nvenc, + HardwareAccelerationMode.Qsv, + HardwareAccelerationMode.Amf, + HardwareAccelerationMode.Cpu + ]; + public RelayCommand ChooseTempDirectoryCommand { get; } + public RelayCommand SaveSettingsCommand { get; } + public RelayCommand CancelSettingsCommand { get; } + public RelayCommand CheckToolsCommand { get; } + public RelayCommand AddConversionProfileCommand { get; } + public RelayCommand RemoveConversionProfileCommand { get; } + + /// Проверка звука и Windows toast без учёта флагов в настройках. + public RelayCommand TestWindowsNotificationCommand { get; } + + public string ProcessingTempDirectory + { + get => _processingTempDirectory; + set + { + if (_processingTempDirectory == value) + { + return; + } + + _processingTempDirectory = value; + OnPropertyChanged(); + } + } + + public string MinimumFileLogLevel + { + get => _minimumFileLogLevel; + set + { + if (_minimumFileLogLevel == value) + { + return; + } + + _minimumFileLogLevel = value; + ApplyLogLevelToService(); + OnPropertyChanged(); + } + } + + public string HardwareAcceleration + { + get => _hardwareAcceleration; + set + { + if (_hardwareAcceleration == value) + { + return; + } + + _hardwareAcceleration = value; + OnPropertyChanged(); + } + } + + /// Звук Windows после успешной/неуспешной обработки всей очереди конвертации. + public bool NotifyCompletionSoundAfterQueue + { + get => _notifyCompletionSoundAfterQueue; + set + { + if (_notifyCompletionSoundAfterQueue == value) + { + return; + } + + _notifyCompletionSoundAfterQueue = value; + OnPropertyChanged(); + } + } + + /// Toast Windows после завершения очереди конвертации. + public bool NotifyWindowsToastAfterQueue + { + get => _notifyWindowsToastAfterQueue; + set + { + if (_notifyWindowsToastAfterQueue == value) + { + return; + } + + _notifyWindowsToastAfterQueue = value; + OnPropertyChanged(); + } + } + + public int SelectedTabIndex + { + get => _selectedTabIndex; + set + { + if (_selectedTabIndex == value) + { + return; + } + + _selectedTabIndex = value; + OnPropertyChanged(); + _logging.Debug($"открыта вкладка: {GetTabName(value)}", "ui.tabs"); + } + } + + /// 0–100 для : во время задачи не выше 99.99; 100 при коротком «флеше» после успеха. + public double LongOperationProgressPercent => _longOperationProgressPercent; + + /// Описание текущей задачи для строки статуса (объединение, конвертация, завершение). + public string LongOperationProgressText => _longOperationProgressText; + + /// Показывать «Нет задач», когда нет длительной операции и нет завершающего флеша. + public bool ShowLongOperationIdlePlaceholder => _showLongOperationIdlePlaceholder; + + /// Показывать блок прогресса: конвертация, объединение или краткое завершение 100%. + public bool IsLongOperationRunning => _isLongOperationRunning; + + public string AppStatusText + { + get => _appStatusText; + private set + { + if (_appStatusText == value) + { + return; + } + + _appStatusText = value; + OnPropertyChanged(); + } + } + + public double TaskbarProgressValue + { + get => _taskbarProgressValue; + private set + { + if (Math.Abs(_taskbarProgressValue - value) < 0.0001) + { + return; + } + + _taskbarProgressValue = value; + OnPropertyChanged(); + } + } + + public TaskbarItemProgressState TaskbarProgressState + { + get => _taskbarProgressState; + private set + { + if (_taskbarProgressState == value) + { + return; + } + + _taskbarProgressState = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + private void ExecuteChooseTempDirectory() + { + var dialog = new OpenFolderDialog + { + Title = "Выберите TEMP-каталог", + InitialDirectory = _recentPathService.GetInitialDirectory( + RecentPathScenario.SettingsTempFolder, + extraFolderFallbackBeforeDefault: ProcessingTempDirectory), + }; + + if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName)) + { + return; + } + + _recentPathService.RememberChosenFolder(RecentPathScenario.SettingsTempFolder, dialog.FolderName); + + ProcessingTempDirectory = dialog.FolderName; + _logging.Info($"выбран TEMP-каталог: {dialog.FolderName}", "settings"); + } + + private void ExecuteSaveSettings() + { + var updated = new AppSettings + { + ProcessingTempDirectory = ProcessingTempDirectory, + MinimumFileLogLevel = MinimumFileLogLevel, + HardwareAcceleration = HardwareAcceleration, + IsLogCollapsed = true, + CopyPreviousTrackSettings = Conversion.CopyPreviousTrackSettings, + DisableSubtitleDefault = Conversion.DisableSubtitleDefault, + NotifyCompletionSoundAfterQueue = NotifyCompletionSoundAfterQueue, + NotifyWindowsToastAfterQueue = NotifyWindowsToastAfterQueue, + ConversionProfiles = ConversionProfiles + .Select(profile => new ConversionProfileSettingsEntry + { + Profile = profile.Profile, + Container = profile.Container, + Video = profile.Video, + PixelFormat = profile.PixelFormat, + Resolution = profile.Resolution, + Fps = profile.Fps, + Audio = profile.Audio, + Bitrate = profile.Bitrate, + VideoBitrateMode = profile.VideoBitrateMode, + VideoBitrateMbps = profile.VideoBitrateMbps, + Subtitles = profile.Subtitles, + ExternalTracks = profile.ExternalTracks, + ExternalSubtitles = profile.ExternalSubtitles, + Fonts = profile.Fonts + }) + .ToList() + }; + + _recentPathService.ApplyTo(updated); + _settingsService.Save(updated); + _loadedSettings = updated; + _recentPathService.HydrateFrom(updated); + Conversion.RecalculateAllAnalyzedForProfileUpdate(); + _logging.Info("настройки сохранены", "settings"); + } + + private void ExecuteCancelSettings() + { + _loadedSettings = _settingsService.Load(); + _recentPathService.HydrateFrom(_loadedSettings); + ProcessingTempDirectory = _loadedSettings.ProcessingTempDirectory; + MinimumFileLogLevel = _loadedSettings.MinimumFileLogLevel; + HardwareAcceleration = _loadedSettings.HardwareAcceleration; + NotifyCompletionSoundAfterQueue = _loadedSettings.NotifyCompletionSoundAfterQueue; + NotifyWindowsToastAfterQueue = _loadedSettings.NotifyWindowsToastAfterQueue; + LoadConversionProfiles(_loadedSettings.ConversionProfiles); + Conversion.CopyPreviousTrackSettings = _loadedSettings.CopyPreviousTrackSettings; + Conversion.DisableSubtitleDefault = _loadedSettings.DisableSubtitleDefault; + _logging.Info("изменения в настройках отменены", "settings"); + } + + private void ExecuteTestWindowsNotification() + { + _notificationService.ShowSettingsTestNotification(); + } + + private void ExecuteCheckTools() + { + var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe"); + var ffprobePath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffprobe.exe"); + + var ffmpegOk = File.Exists(ffmpegPath); + var ffprobeOk = File.Exists(ffprobePath); + + if (ffmpegOk && ffprobeOk) + { + _logging.Info("инструменты OK (ffmpeg/ffprobe)", "tools.check"); + return; + } + + if (!ffmpegOk) + { + _logging.Error($"не найден ffmpeg: {ffmpegPath}", "tools.check"); + } + + if (!ffprobeOk) + { + _logging.Error($"не найден ffprobe: {ffprobePath}", "tools.check"); + } + } + + private void ApplyLogLevelToService() + { + if (Enum.TryParse(MinimumFileLogLevel, true, out var level)) + { + _logging.MinimumFileLogLevel = level; + return; + } + + _logging.MinimumFileLogLevel = LogLevel.Info; + } + + private void LoadConversionProfiles(IEnumerable profiles) + { + ConversionProfiles.Clear(); + foreach (var profile in profiles) + { + ConversionProfiles.Add(new ConversionProfilePresetRow + { + IsBuiltIn = ConversionProfileNames.IsBuiltIn(profile.Profile), + Profile = profile.Profile, + Container = profile.Container, + Video = profile.Video, + PixelFormat = profile.PixelFormat, + Resolution = profile.Resolution, + Fps = profile.Fps, + Audio = profile.Audio, + Bitrate = profile.Bitrate, + VideoBitrateMode = profile.VideoBitrateMode, + VideoBitrateMbps = profile.VideoBitrateMbps, + Subtitles = profile.Subtitles, + ExternalTracks = profile.ExternalTracks, + ExternalSubtitles = profile.ExternalSubtitles, + Fonts = profile.Fonts + }); + } + + SelectedConversionProfile = null; + + if (Conversion is not null) + { + Conversion.SyncDefaultProfileFromList(ConversionProfiles.ToList()); + } + } + + /// Применяет профили из загруженного .conv_setup (нормализация как при чтении settings.json). + private void ApplyConversionProfilesFromQueueSetupDocument(IReadOnlyList raw) + { + if (raw is null || raw.Count == 0) + { + return; + } + + var normalized = AppSettingsService.NormalizeStoredConversionProfiles(raw.ToList()); + LoadConversionProfiles(normalized); + } + + private void ExecuteAddConversionProfile() + { + var name = GenerateUniqueCustomProfileName(); + var row = CreateCustomProfileRow(name); + ConversionProfiles.Add(row); + SelectedConversionProfile = row; + _logging.Info($"добавлен пользовательский профиль: {name}", "settings.profiles"); + } + + private void ExecuteRemoveConversionProfile() + { + if (SelectedConversionProfile is not { IsBuiltIn: false } row) + { + return; + } + + var removedName = row.Profile; + ConversionProfiles.Remove(row); + if (SelectedConversionProfile == row) + { + SelectedConversionProfile = null; + } + + _logging.Info($"удалён пользовательский профиль: {removedName}", "settings.profiles"); + } + + private bool CanRemoveConversionProfile() => SelectedConversionProfile is { IsBuiltIn: false }; + + private string GenerateUniqueCustomProfileName() + { + for (var i = 1; i < 1000; i++) + { + var candidate = $"Мой профиль {i}"; + if (ConversionProfiles.All(p => !p.Profile.Equals(candidate, StringComparison.OrdinalIgnoreCase))) + { + return candidate; + } + } + + return $"Мой профиль {Guid.NewGuid():N}"[..8]; + } + + private static ConversionProfilePresetRow CreateCustomProfileRow(string name) + { + return new ConversionProfilePresetRow + { + IsBuiltIn = false, + Profile = name, + Container = "MP4", + Video = "H.264", + PixelFormat = "yuv420p", + Resolution = "Без изменений", + Fps = "Без изменений", + Audio = "AAC", + Bitrate = "256 kbps", + VideoBitrateMode = VideoBitratePolicy.Auto, + Subtitles = "Да", + ExternalTracks = "Да", + ExternalSubtitles = "Да", + Fonts = "Нет" + }; + } + + private static string GetTabName(int index) + { + return index switch + { + 0 => "Переименование сериалов", + 1 => "Конвертация", + 2 => "Объединение", + 3 => "Извлечение дорожек", + 4 => "Video info", + 5 => "Настройки", + 6 => "Логи", + _ => "Неизвестно" + }; + } + + private void OnUiEntriesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => + Logs.RaiseClearCommandState(); + + private void OnConversionPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ConversionViewModel.CopyPreviousTrackSettings)) + { + PersistCopyPreviousTrackSettings(); + } + else if (e.PropertyName == nameof(ConversionViewModel.DisableSubtitleDefault)) + { + PersistDisableSubtitleDefault(); + } + + if (e.PropertyName == nameof(ConversionViewModel.IsExecutionRunning) + || e.PropertyName == nameof(ConversionViewModel.OverallProgressPercent) + || e.PropertyName == nameof(ConversionViewModel.OverallQueueDoneCount) + || e.PropertyName == nameof(ConversionViewModel.OverallQueueTotal) + || e.PropertyName == nameof(ConversionViewModel.CurrentRunId) + || e.PropertyName == nameof(ConversionViewModel.ExecutionPhaseCaption)) + { + RefreshStatusBar(); + } + } + + private void OnMergePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(MergeViewModel.IsRunning) + or nameof(MergeViewModel.ProgressPercent) + or nameof(MergeViewModel.LastMergeCompletion)) + { + RefreshStatusBar(); + } + } + + private void OnTrackExtractionPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(TrackExtractionViewModel.IsBusy) + or nameof(TrackExtractionViewModel.OverallProgressPercent) + or nameof(TrackExtractionViewModel.ExecutionPhaseCaption) + or nameof(TrackExtractionViewModel.LastRunOutcome)) + { + RefreshStatusBar(); + } + } + + private void PersistCopyPreviousTrackSettings() + { + try + { + var settings = _settingsService.Load(); + settings.CopyPreviousTrackSettings = Conversion.CopyPreviousTrackSettings; + _settingsService.Save(settings); + _loadedSettings = settings; + } + catch + { + // ignore persistence errors for UI convenience flag + } + } + + private void PersistDisableSubtitleDefault() + { + try + { + var settings = _settingsService.Load(); + settings.DisableSubtitleDefault = Conversion.DisableSubtitleDefault; + _settingsService.Save(settings); + _loadedSettings = settings; + } + catch + { + // ignore persistence errors for UI convenience flag + } + } + + private void RefreshStatusBar() + { + var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + if (!dispatcher.CheckAccess()) + { + dispatcher.BeginInvoke(RefreshStatusBar, DispatcherPriority.Normal); + return; + } + + var convRunning = Conversion.IsExecutionRunning; + if (convRunning && !_wasConversionExecuting) + { + _queueEndedWithConversionErrors = false; + } + + if (_wasConversionExecuting && !convRunning) + { + var runId = Conversion.CurrentRunId; + var runItems = string.IsNullOrWhiteSpace(runId) + ? Conversion.QueueTasks.ToList() + : Conversion.QueueTasks.Where(i => string.Equals(i.LastRunId, runId, StringComparison.Ordinal)).ToList(); + var hasError = runItems.Any(i => i.Status == ConversionQueueStatus.Error); + var hasCancelled = runItems.Any(i => i.Status == ConversionQueueStatus.Cancelled); + _queueEndedWithConversionErrors = hasError; + if (!hasError && !hasCancelled && runItems.Count > 0) + { + ScheduleCompletionFlash(); + } + } + + _wasConversionExecuting = convRunning; + + var mergeRunning = Merge.IsRunning; + if (_wasMergeRunning && !mergeRunning) + { + if (Merge.LastMergeCompletion == MergeCompletionKind.Success) + { + ScheduleCompletionFlash(); + } + } + + _wasMergeRunning = mergeRunning; + + var trackBusy = TrackExtraction.IsBusy; + if (_wasTrackExtractionBusy && !trackBusy) + { + if (TrackExtraction.LastRunOutcome == TrackExtractionRunOutcome.Success) + { + ScheduleCompletionFlash(); + } + } + + _wasTrackExtractionBusy = trackBusy; + + RefreshTaskbarProgress(); + RefreshLongOperationUiProperties(); + RefreshAppStatusText(); + } + + private void ScheduleCompletionFlash() + { + var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + if (!dispatcher.CheckAccess()) + { + dispatcher.BeginInvoke(ScheduleCompletionFlash, DispatcherPriority.Normal); + return; + } + + if (_completionFlashTimer is not null) + { + _completionFlashTimer.Stop(); + _completionFlashTimer.Tick -= OnCompletionFlashTick; + _completionFlashTimer = null; + } + + _completionFlashActive = true; + RefreshLongOperationUiProperties(); + + _completionFlashTimer = new DispatcherTimer(DispatcherPriority.Normal) + { + Interval = TimeSpan.FromMilliseconds(750), + }; + _completionFlashTimer.Tick += OnCompletionFlashTick; + _completionFlashTimer.Start(); + } + + private void OnCompletionFlashTick(object? sender, EventArgs e) + { + if (_completionFlashTimer is not null) + { + _completionFlashTimer.Stop(); + _completionFlashTimer.Tick -= OnCompletionFlashTick; + _completionFlashTimer = null; + } + + _completionFlashActive = false; + RefreshLongOperationUiProperties(); + RefreshAppStatusText(); + } + + private void RefreshLongOperationUiProperties() + { + var convRunning = Conversion.IsExecutionRunning; + var mergeRunning = Merge.IsRunning; + var trackExtractBusy = TrackExtraction.IsBusy; + var showUi = convRunning || mergeRunning || trackExtractBusy || _completionFlashActive; + + double display; + if (_completionFlashActive) + { + display = 100.0; + } + else if (convRunning) + { + display = Math.Min(Conversion.OverallProgressPercent, 99.99); + } + else if (mergeRunning) + { + display = Math.Min(Merge.ProgressPercent, 99.99); + } + else if (trackExtractBusy) + { + display = Math.Min(TrackExtraction.OverallProgressPercent, 99.99); + } + else + { + display = 0.0; + } + + string taskText; + if (_completionFlashActive) + { + taskText = "Готово"; + } + else if (convRunning) + { + taskText = string.IsNullOrEmpty(Conversion.ExecutionPhaseCaption) + ? "Конвертация..." + : Conversion.ExecutionPhaseCaption; + } + else if (mergeRunning) + { + taskText = "Объединение..."; + } + else if (trackExtractBusy) + { + taskText = string.IsNullOrEmpty(TrackExtraction.ExecutionPhaseCaption) + ? "Извлечение дорожек..." + : TrackExtraction.ExecutionPhaseCaption; + } + else + { + taskText = string.Empty; + } + + var idle = !showUi; + if (_showLongOperationIdlePlaceholder != idle) + { + _showLongOperationIdlePlaceholder = idle; + OnPropertyChanged(nameof(ShowLongOperationIdlePlaceholder)); + } + + if (Math.Abs(_longOperationProgressPercent - display) > 0.0001) + { + _longOperationProgressPercent = display; + OnPropertyChanged(nameof(LongOperationProgressPercent)); + } + + if (_longOperationProgressText != taskText) + { + _longOperationProgressText = taskText; + OnPropertyChanged(nameof(LongOperationProgressText)); + } + + if (_isLongOperationRunning != showUi) + { + _isLongOperationRunning = showUi; + OnPropertyChanged(nameof(IsLongOperationRunning)); + } + } + + private void RefreshAppStatusText() + { + if (Conversion.IsExecutionRunning || Merge.IsRunning || TrackExtraction.IsBusy) + { + AppStatusText = "В работе"; + return; + } + + if (_queueEndedWithConversionErrors || Merge.LastMergeCompletion == MergeCompletionKind.Error + || TrackExtraction.LastRunOutcome == TrackExtractionRunOutcome.Error) + { + AppStatusText = "Ошибка"; + return; + } + + AppStatusText = "Готово"; + } + + private void RefreshTaskbarProgress() + { + if (!Conversion.IsExecutionRunning) + { + TaskbarProgressValue = 0; + TaskbarProgressState = TaskbarItemProgressState.None; + return; + } + + var runId = Conversion.CurrentRunId; + var runItems = string.IsNullOrWhiteSpace(runId) + ? Conversion.QueueTasks.ToList() + : Conversion.QueueTasks.Where(i => string.Equals(i.LastRunId, runId, StringComparison.Ordinal)).ToList(); + + var hasError = runItems.Any(i => i.Status == ConversionQueueStatus.Error); + var hasCancelled = runItems.Any(i => i.Status == ConversionQueueStatus.Cancelled); + + TaskbarProgressState = hasError + ? TaskbarItemProgressState.Error + : hasCancelled + ? TaskbarItemProgressState.Paused + : TaskbarItemProgressState.Normal; + + TaskbarProgressValue = Math.Clamp(Conversion.OverallProgressPercent / 100.0, 0.0, 1.0); + } + +} diff --git a/EmbyToolbox/ViewModels/MergeViewModel.cs b/EmbyToolbox/ViewModels/MergeViewModel.cs new file mode 100644 index 0000000..df78be0 --- /dev/null +++ b/EmbyToolbox/ViewModels/MergeViewModel.cs @@ -0,0 +1,829 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows; +using Microsoft.Win32; +using EmbyToolbox.Models; +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +public sealed class MergeViewModel : INotifyPropertyChanged +{ + private readonly LoggingService _logging; + private readonly MergeService _mergeService; + private readonly RecentPathService _recentPaths; + private string _mergedOutputPath = string.Empty; + private bool _isRunning; + private int _progressPercent; + private string _progressText = "Готово"; + private string _validationMessage = string.Empty; + private MergeFileItem? _selectedItem; + private CancellationTokenSource? _mergeCts; + private bool _isMergeDropHighlight; + private MergeCompletionKind _lastMergeCompletion = MergeCompletionKind.None; + private readonly List _selectedItems = []; + + public MergeViewModel(LoggingService logging, MergeService mergeService, RecentPathService recentPaths) + { + _logging = logging; + _mergeService = mergeService; + _recentPaths = recentPaths; + Files.CollectionChanged += OnFilesCollectionChanged; + + SelectVideoFilesCommand = new RelayCommand(ExecuteSelectVideoFiles, () => !IsRunning); + SelectOutputFileCommand = new RelayCommand(ExecuteSelectOutputFile, () => !IsRunning); + MoveUpCommand = new RelayCommand(ExecuteMoveUp, CanMoveUp); + MoveDownCommand = new RelayCommand(ExecuteMoveDown, CanMoveDown); + RefreshCommand = new RelayCommand(ExecuteRefresh, () => !IsRunning && Files.Count > 0); + RemoveFromListCommand = new RelayCommand(ExecuteRemoveFromList, CanRemoveFromList); + ClearListCommand = new RelayCommand(ExecuteClearList, () => !IsRunning && Files.Count > 0); + MergeCommand = new RelayCommand(async () => await ExecuteMergeAsync(), CanMerge); + CancelOrClearCommand = new RelayCommand(ExecuteCancelOrClear); + } + + public ObservableCollection Files { get; } = new(); + + public RelayCommand SelectVideoFilesCommand { get; } + public RelayCommand SelectOutputFileCommand { get; } + + public bool IsMergeDropHighlight + { + get => _isMergeDropHighlight; + internal set + { + if (_isMergeDropHighlight == value) + { + return; + } + + _isMergeDropHighlight = value; + OnPropertyChanged(); + } + } + public RelayCommand MoveUpCommand { get; } + public RelayCommand MoveDownCommand { get; } + public RelayCommand RefreshCommand { get; } + public RelayCommand RemoveFromListCommand { get; } + public RelayCommand ClearListCommand { get; } + public RelayCommand MergeCommand { get; } + public RelayCommand CancelOrClearCommand { get; } + + public string MergedOutputPath + { + get => _mergedOutputPath; + set + { + if (_mergedOutputPath == value) + { + return; + } + + _mergedOutputPath = value; + OnPropertyChanged(); + UpdateValidationState(); + RaiseCommandStates(); + } + } + + public bool IsRunning + { + get => _isRunning; + private set + { + if (_isRunning == value) + { + return; + } + + _isRunning = value; + OnPropertyChanged(); + RaiseCommandStates(); + } + } + + public int ProgressPercent + { + get => _progressPercent; + private set + { + if (_progressPercent == value) + { + return; + } + + _progressPercent = value; + OnPropertyChanged(); + } + } + + public string ProgressText + { + get => _progressText; + private set + { + if (_progressText == value) + { + return; + } + + _progressText = value; + OnPropertyChanged(); + } + } + + public string ValidationMessage + { + get => _validationMessage; + private set + { + if (_validationMessage == value) + { + return; + } + + _validationMessage = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasValidationMessage)); + } + } + + public bool HasValidationMessage => !string.IsNullOrWhiteSpace(ValidationMessage); + + /// Итог последней операции объединения (для строки состояния главного окна). + public MergeCompletionKind LastMergeCompletion => _lastMergeCompletion; + + public MergeFileItem? SelectedItem + { + get => _selectedItem; + set + { + if (_selectedItem == value) + { + return; + } + + _selectedItem = value; + OnPropertyChanged(); + RaiseCommandStates(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void UpdateSelectedItems(IReadOnlyList selectedItems) + { + _selectedItems.Clear(); + _selectedItems.AddRange(selectedItems); + RaiseCommandStates(); + } + + private void ExecuteSelectVideoFiles() + { + var dialog = new OpenFileDialog + { + Title = "Выберите видеофайлы для объединения", + Filter = SupportedVideoFormats.BuildOpenFileDialogFilter(), + Multiselect = true, + InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.Merge), + }; + + if (dialog.ShowDialog() != true || dialog.FileNames is not { Length: > 0 }) + { + return; + } + + if (dialog.FileNames.Length > 0) + { + _recentPaths.RememberChosenFiles(RecentPathScenario.Merge, dialog.FileNames); + } + + LoadFilesFromFilePicker(dialog.FileNames); + } + + private void ExecuteSelectOutputFile() + { + var suggestFile = "movies_merged.mkv"; + string? suggestDir = null; + try + { + if (!string.IsNullOrWhiteSpace(MergedOutputPath)) + { + var full = Path.GetFullPath(MergedOutputPath.Trim()); + suggestDir = Path.GetDirectoryName(full); + var fn = Path.GetFileName(full); + if (!string.IsNullOrEmpty(fn)) + { + suggestFile = fn; + } + } + } + catch + { + // ignore malformed path + } + + suggestDir ??= _recentPaths.GetInitialDirectory(RecentPathScenario.Merge); + + var dlg = new SaveFileDialog + { + Title = "Итоговый файл", + Filter = "Matroska (*.mkv)|*.mkv", + DefaultExt = ".mkv", + AddExtension = true, + FileName = suggestFile, + }; + + if (!string.IsNullOrWhiteSpace(suggestDir) && Directory.Exists(suggestDir)) + { + dlg.InitialDirectory = suggestDir; + } + + if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.FileName)) + { + return; + } + + var chosen = Path.GetFullPath(dlg.FileName); + MergedOutputPath = chosen; + _recentPaths.RememberChosenFiles(RecentPathScenario.Merge, [chosen]); + } + + /// Drag & drop: добавить файлы/каталог (только видео первого уровня) к списку без дублей. + public void ApplyDroppedPaths(IReadOnlyList rawPaths) + { + if (IsRunning || rawPaths is null || rawPaths.Count == 0) + { + return; + } + + IsMergeDropHighlight = false; + + var discovered = new List(); + foreach (var raw in rawPaths) + { + try + { + var full = Path.GetFullPath(raw); + if (File.Exists(full)) + { + if (SupportedVideoFormats.IsSupportedVideoFile(full)) + { + discovered.Add(full); + } + else + { + _logging.Warning($"объединение (drop): не поддерживаемое расширение, пропуск: {full}", "merge"); + } + + continue; + } + + if (Directory.Exists(full)) + { + foreach (var top in EnumerateTopLevelVideoFilesOnly(full)) + { + discovered.Add(top); + } + } + } + catch (Exception ex) + { + _logging.Warning($"объединение (drop): не удалось обработать «{raw}»: {ex.Message}", "merge"); + } + } + + if (discovered.Count == 0) + { + _logging.Warning("объединение (drop): нет подходящих видеофайлов", "merge"); + return; + } + + var combined = new HashSet(Files.Select(f => f.FullPath), StringComparer.OrdinalIgnoreCase); + var added = 0; + foreach (var p in discovered) + { + if (combined.Add(p)) + { + added++; + } + } + + if (added == 0) + { + _logging.Warning("объединение (drop): все элементы уже в списке", "merge"); + return; + } + + var sorted = combined.OrderBy(p => p, FileDiscoveryService.QueuePathOrderComparer).ToList(); + RebuildMergeItems(sorted); + AfterFilesChanged(); + _logging.Info($"объединение (drop): добавлено файлов: {added}, всего в списке: {Files.Count}", "merge"); + } + + private static IEnumerable EnumerateTopLevelVideoFilesOnly(string directoryFullPath) + { + foreach (var file in Directory.EnumerateFiles(directoryFullPath)) + { + if (!SupportedVideoFormats.IsSupportedVideoFile(file)) + { + continue; + } + + string normalized; + try + { + normalized = Path.GetFullPath(file); + } + catch + { + continue; + } + + yield return normalized; + } + } + + private void LoadFilesFromFilePicker(string[] fileNames) + { + var list = new List(); + foreach (var raw in fileNames) + { + try + { + var full = Path.GetFullPath(raw); + if (!File.Exists(full)) + { + continue; + } + + if (!SupportedVideoFormats.IsSupportedVideoFile(full)) + { + _logging.Warning($"объединение: файл не поддерживается и пропущен: {full}", "merge"); + continue; + } + + list.Add(full); + } + catch (Exception ex) + { + _logging.Warning($"объединение: неверный путь «{raw}»: {ex.Message}", "merge"); + } + } + + var sorted = list.OrderBy(p => p, FileDiscoveryService.QueuePathOrderComparer).ToList(); + RebuildMergeItems(sorted); + AfterFilesChanged(); + _logging.Info($"объединение: выбрано файлов из диалога: {sorted.Count}", "merge"); + } + + private void ExecuteMoveUp() + { + if (SelectedItem is null) + { + return; + } + + var idx = Files.IndexOf(SelectedItem); + if (idx <= 0) + { + return; + } + + Files.Move(idx, idx - 1); + RenumberMergeRows(); + } + + private void ExecuteMoveDown() + { + if (SelectedItem is null) + { + return; + } + + var idx = Files.IndexOf(SelectedItem); + if (idx < 0 || idx >= Files.Count - 1) + { + return; + } + + Files.Move(idx, idx + 1); + RenumberMergeRows(); + } + + private void ExecuteRefresh() + { + if (Files.Count == 0) + { + return; + } + + var sorted = Files + .Select(f => f.FullPath) + .OrderBy(p => p, FileDiscoveryService.QueuePathOrderComparer) + .ToList(); + RebuildMergeItems(sorted); + AfterFilesChanged(); + _logging.Debug("объединение: порядок обновлён по алфавиту полных путей", "merge"); + } + + private void ExecuteRemoveFromList() + { + if (_selectedItems.Count == 0 && SelectedItem is null) + { + return; + } + + var targets = _selectedItems.Count > 0 + ? _selectedItems.ToList() + : [SelectedItem!]; + + foreach (var item in targets) + { + Files.Remove(item); + } + + SelectedItem = null; + _selectedItems.Clear(); + RenumberMergeRows(); + } + + private void ExecuteClearList() + { + if (Files.Count == 0) + { + return; + } + + Files.Clear(); + _selectedItems.Clear(); + SelectedItem = null; + MergedOutputPath = string.Empty; + ProgressPercent = 0; + ProgressText = "Готово"; + ValidationMessage = string.Empty; + RaiseCommandStates(); + } + + private void SetLastMergeCompletion(MergeCompletionKind value) + { + if (_lastMergeCompletion == value) + { + return; + } + + _lastMergeCompletion = value; + OnPropertyChanged(nameof(LastMergeCompletion)); + } + + private async Task ExecuteMergeAsync() + { + if (!CanMerge()) + { + return; + } + + string outputPath; + try + { + outputPath = Path.GetFullPath(MergedOutputPath.Trim()); + } + catch (Exception ex) + { + ProgressText = $"Ошибка пути: {ex.Message}"; + _logging.Error($"объединение: некорректный путь выхода: {ex.Message}", "merge", ex); + return; + } + + var outDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir); + } + + var ordered = Files.ToList(); + + _mergeCts?.Dispose(); + _mergeCts = new CancellationTokenSource(); + + try + { + SetLastMergeCompletion(MergeCompletionKind.None); + IsRunning = true; + ProgressPercent = 0; + ProgressText = "Объединение..."; + foreach (var file in Files) + { + file.Status = "В очереди"; + } + + var progress = new Progress(p => + { + ProgressPercent = p; + ProgressText = $"Объединение... {p}%"; + }); + + foreach (var file in ordered) + { + file.Status = "Обработка"; + } + + await _mergeService.MergeAsync(ordered, outputPath, progress, _mergeCts.Token); + + foreach (var file in Files) + { + file.Status = "Готово"; + } + + TryDeleteSourcesAfterSuccess(ordered); + + SetLastMergeCompletion(MergeCompletionKind.Success); + ProgressPercent = 100; + ProgressText = $"Готово: {Path.GetFileName(outputPath)}"; + } + catch (OperationCanceledException) + { + foreach (var file in Files.Where(f => f.Status == "Обработка")) + { + file.Status = "Отмена"; + } + + SetLastMergeCompletion(MergeCompletionKind.Cancelled); + ProgressText = "Операция отменена"; + _logging.Warning("объединение отменено пользователем", "merge"); + } + catch (Exception ex) + { + foreach (var file in Files) + { + if (file.Status != "Готово") + { + file.Status = "Ошибка"; + } + } + + SetLastMergeCompletion(MergeCompletionKind.Error); + ProgressText = $"Ошибка: {ex.Message}"; + _logging.Error($"ошибка объединения: {ex.Message}", "merge", ex); + } + finally + { + IsRunning = false; + } + } + + private void ExecuteCancelOrClear() + { + if (IsRunning) + { + _mergeCts?.Cancel(); + return; + } + + SetLastMergeCompletion(MergeCompletionKind.None); + Files.Clear(); + _selectedItems.Clear(); + SelectedItem = null; + MergedOutputPath = string.Empty; + ProgressPercent = 0; + ProgressText = "Готово"; + ValidationMessage = string.Empty; + } + + private bool CanMoveUp() + { + if (IsRunning || SelectedItem is null || _selectedItems.Count > 1) + { + return false; + } + + return Files.IndexOf(SelectedItem) > 0; + } + + private bool CanMoveDown() + { + if (IsRunning || SelectedItem is null || _selectedItems.Count > 1) + { + return false; + } + + var idx = Files.IndexOf(SelectedItem); + return idx >= 0 && idx < Files.Count - 1; + } + + private bool CanMerge() + { + return !IsRunning && ValidateInputs(showMessage: false); + } + + private void RebuildMergeItems(IReadOnlyList sortedFullPaths) + { + Files.Clear(); + for (var i = 0; i < sortedFullPaths.Count; i++) + { + var fullPath = sortedFullPaths[i]; + var fileName = Path.GetFileName(fullPath); + var item = new MergeFileItem + { + FullPath = fullPath, + FileName = fileName, + SizeMb = (int)Math.Round(new FileInfo(fullPath).Length / (1024d * 1024d), MidpointRounding.AwayFromZero), + Number = i + 1, + Status = "Готов", + }; + item.SyncAutoPartName($"Часть {i + 1} - {Path.GetFileNameWithoutExtension(fileName)}"); + Files.Add(item); + } + + UpdateDefaultMergedOutputPath(sortedFullPaths); + } + + private void UpdateDefaultMergedOutputPath(IReadOnlyList sortedFullPaths) + { + if (sortedFullPaths.Count == 0) + { + MergedOutputPath = string.Empty; + return; + } + + MergedOutputPath = BuildDefaultMergedOutputFullPath(sortedFullPaths); + } + + private string BuildDefaultMergedOutputFullPath(IReadOnlyList sortedFullPaths) + { + var parents = sortedFullPaths + .Select(static p => Path.GetDirectoryName(p)!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var dir = parents.Count == 1 + ? parents[0] + : _recentPaths.GetInitialDirectory(RecentPathScenario.Merge); + + var fileName = parents.Count == 1 + ? $"{new DirectoryInfo(parents[0]).Name}_merged.mkv" + : "movies_merged.mkv"; + + return Path.Combine(dir, fileName); + } + + private void AfterFilesChanged() + { + ProgressPercent = 0; + ProgressText = Files.Count < 2 + ? "Нужно минимум 2 файла для объединения" + : "Готово"; + + UpdateValidationState(); + RaiseCommandStates(); + } + + private void RenumberMergeRows() + { + for (var i = 0; i < Files.Count; i++) + { + Files[i].Number = i + 1; + var fn = Files[i].FileName; + Files[i].SyncAutoPartName($"Часть {i + 1} - {Path.GetFileNameWithoutExtension(fn)}"); + } + + UpdateValidationState(); + RaiseCommandStates(); + } + + private void OnFilesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateValidationState(); + RaiseCommandStates(); + } + + private void UpdateValidationState() + { + ValidateInputs(showMessage: true); + } + + private bool ValidateInputs(bool showMessage) + { + string? error = null; + + if (Files.Count < 2) + { + error = "Нужно минимум 2 файла для объединения."; + } + else if (string.IsNullOrWhiteSpace(MergedOutputPath)) + { + error = "Укажите полный путь итогового файла."; + } + else + { + string fullOutput; + try + { + fullOutput = Path.GetFullPath(MergedOutputPath.Trim()); + } + catch (Exception ex) + { + error = $"Некорректный путь итогового файла: {ex.Message}"; + fullOutput = string.Empty; + } + + if (error is null) + { + var name = Path.GetFileName(fullOutput); + if (string.IsNullOrWhiteSpace(name)) + { + error = "Укажите имя итогового файла (например, …\\movies_merged.mkv)."; + } + else if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + error = "Имя итогового файла содержит недопустимые символы."; + } + } + } + + if (showMessage) + { + ValidationMessage = error ?? string.Empty; + } + + return string.IsNullOrWhiteSpace(error); + } + + private void RaiseCommandStates() + { + SelectVideoFilesCommand.RaiseCanExecuteChanged(); + SelectOutputFileCommand.RaiseCanExecuteChanged(); + MoveUpCommand.RaiseCanExecuteChanged(); + MoveDownCommand.RaiseCanExecuteChanged(); + RefreshCommand.RaiseCanExecuteChanged(); + RemoveFromListCommand.RaiseCanExecuteChanged(); + ClearListCommand.RaiseCanExecuteChanged(); + MergeCommand.RaiseCanExecuteChanged(); + CancelOrClearCommand.RaiseCanExecuteChanged(); + } + + private bool CanRemoveFromList() + { + return !IsRunning && (_selectedItems.Count > 0 || SelectedItem is not null); + } + + private void TryDeleteSourcesAfterSuccess(IReadOnlyList ordered) + { + if (ordered.Count == 0) + { + return; + } + + var sourceList = string.Join(Environment.NewLine, ordered.Select(f => f.FullPath)); + var question = new StringBuilder() + .AppendLine("Удалить исходные файлы?") + .AppendLine() + .AppendLine(sourceList) + .ToString(); + + var answer = MessageBox.Show( + question, + "Объединение завершено", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (answer != MessageBoxResult.Yes) + { + return; + } + + var deleteErrors = new List(); + foreach (var source in ordered.Select(f => f.FullPath)) + { + try + { + File.Delete(source); + _logging.Info($"удален исходник после объединения: {source}", "merge"); + } + catch (Exception ex) + { + deleteErrors.Add($"{source} ({ex.Message})"); + _logging.Warning($"не удалось удалить исходник: {source} ({ex.Message})", "merge", ex); + } + } + + if (deleteErrors.Count == 0) + { + return; + } + + MessageBox.Show( + "Не удалось удалить некоторые файлы:" + Environment.NewLine + Environment.NewLine + string.Join(Environment.NewLine, deleteErrors), + "Удаление исходников", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/EmbyToolbox/ViewModels/RelayCommand.cs b/EmbyToolbox/ViewModels/RelayCommand.cs new file mode 100644 index 0000000..19efa08 --- /dev/null +++ b/EmbyToolbox/ViewModels/RelayCommand.cs @@ -0,0 +1,38 @@ +using System.Windows.Input; + +namespace EmbyToolbox.ViewModels; + +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = _ => execute(); + _canExecute = canExecute is null ? null : _ => canExecute(); + } + + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public bool CanExecute(object? parameter) + { + return _canExecute?.Invoke(parameter) ?? true; + } + + public void Execute(object? parameter) + { + _execute(parameter); + } + + public event EventHandler? CanExecuteChanged; + + public void RaiseCanExecuteChanged() + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/EmbyToolbox/ViewModels/RenameTreeNodeViewModel.cs b/EmbyToolbox/ViewModels/RenameTreeNodeViewModel.cs new file mode 100644 index 0000000..01fab36 --- /dev/null +++ b/EmbyToolbox/ViewModels/RenameTreeNodeViewModel.cs @@ -0,0 +1,62 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace EmbyToolbox.ViewModels; + +public enum RenameTreeSide +{ + Current, + Preview +} + +public sealed class RenameTreeNodeViewModel : INotifyPropertyChanged +{ + private bool _isExpanded; + private bool _isSelected; + + public required string Name { get; init; } + public required string Kind { get; init; } + public required string IconGlyph { get; init; } + public required string NodeKey { get; init; } + public required RenameTreeSide Side { get; init; } + + public ObservableCollection Children { get; } = new(); + + public bool IsExpanded + { + get => _isExpanded; + set + { + if (_isExpanded == value) + { + return; + } + + _isExpanded = value; + OnPropertyChanged(); + } + } + + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected == value) + { + return; + } + + _isSelected = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/EmbyToolbox/ViewModels/SeriesRenamerViewModel.cs b/EmbyToolbox/ViewModels/SeriesRenamerViewModel.cs new file mode 100644 index 0000000..64e2d48 --- /dev/null +++ b/EmbyToolbox/ViewModels/SeriesRenamerViewModel.cs @@ -0,0 +1,334 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using EmbyToolbox.Services; +using Microsoft.Win32; + +namespace EmbyToolbox.ViewModels; + +public sealed class SeriesRenamerViewModel : INotifyPropertyChanged +{ + private readonly SeriesRenamerService _service; + private readonly LoggingService _logging; + private readonly RecentPathService _recentPaths; + + private string _rootFolderPath = string.Empty; + private string _seriesName = string.Empty; + private string _unsupportedReason = string.Empty; + private bool _isPreviewSupported; + private bool _isRootTreeDragOver; + private bool _isSynchronizingTrees; + private readonly Dictionary _currentByKey = new(StringComparer.Ordinal); + private readonly Dictionary _previewByKey = new(StringComparer.Ordinal); + + private SeriesRenamePreview _currentPreview = SeriesRenamePreview.Unsupported("Папка сериала не выбрана."); + + public SeriesRenamerViewModel(SeriesRenamerService service, LoggingService logging, RecentPathService recentPaths) + { + _service = service; + _logging = logging; + _recentPaths = recentPaths; + + SelectRootFolderCommand = new RelayCommand(ExecuteSelectRootFolder); + RefreshPreviewCommand = new RelayCommand(ExecuteRefreshPreview); + RunRenameCommand = new RelayCommand(ExecuteRunRename, () => IsPreviewSupported); + } + + public ObservableCollection CurrentTree { get; } = new(); + public ObservableCollection PreviewTree { get; } = new(); + + public RelayCommand SelectRootFolderCommand { get; } + public RelayCommand RefreshPreviewCommand { get; } + public RelayCommand RunRenameCommand { get; } + + public bool IsRootTreeDragOver + { + get => _isRootTreeDragOver; + internal set + { + if (_isRootTreeDragOver == value) + { + return; + } + + _isRootTreeDragOver = value; + OnPropertyChanged(); + } + } + + /// Перетаскивание в дерево текущей структуры: только один каталог как корень сериала. + public void ApplyDroppedPaths(IReadOnlyList paths) + { + if (paths is null || paths.Count == 0) + { + return; + } + + IsRootTreeDragOver = false; + + foreach (var raw in paths) + { + try + { + var full = Path.GetFullPath(raw); + if (Directory.Exists(full)) + { + ApplyRootFolder(full, fromDragDrop: true); + return; + } + + if (File.Exists(full)) + { + _logging.Warning( + $"переименование сериалов: ожидалась папка, файл пропущен: {full}", + "series-renamer"); + } + } + catch (Exception ex) + { + _logging.Warning( + $"переименование сериалов: неверный путь «{raw}»: {ex.Message}", + "series-renamer"); + } + } + } + + public string RootFolderPath + { + get => _rootFolderPath; + set + { + if (_rootFolderPath == value) + { + return; + } + + _rootFolderPath = value; + OnPropertyChanged(); + } + } + + public string SeriesName + { + get => _seriesName; + set + { + if (_seriesName == value) + { + return; + } + + _seriesName = value; + OnPropertyChanged(); + RebuildPreview(); + } + } + + public bool IsPreviewSupported + { + get => _isPreviewSupported; + private set + { + if (_isPreviewSupported == value) + { + return; + } + + _isPreviewSupported = value; + OnPropertyChanged(); + RunRenameCommand.RaiseCanExecuteChanged(); + } + } + + public string UnsupportedReason + { + get => _unsupportedReason; + private set + { + if (_unsupportedReason == value) + { + return; + } + + _unsupportedReason = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void ExecuteSelectRootFolder() + { + var dialog = new OpenFolderDialog + { + Title = "Выберите корневую папку сериала", + InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.SeriesRenamer), + }; + + if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName)) + { + return; + } + + ApplyRootFolder(dialog.FolderName, fromDragDrop: false); + } + + private void ApplyRootFolder(string directoryPath, bool fromDragDrop) + { + if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath)) + { + return; + } + + var full = Path.GetFullPath(directoryPath); + _recentPaths.RememberChosenFolder(RecentPathScenario.SeriesRenamer, full); + RootFolderPath = full; + SeriesName = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + _logging.Info( + fromDragDrop + ? $"папка сериала (drag & drop): {full}" + : $"выбрана папка сериала: {full}", + "series-renamer"); + RebuildPreview(); + } + + private void ExecuteRefreshPreview() + { + RebuildPreview(); + _logging.Debug("предпросмотр переименования обновлен вручную", "series-renamer"); + } + + private void RebuildPreview() + { + _currentPreview = _service.BuildPreview(RootFolderPath, SeriesName); + CurrentTree.Clear(); + PreviewTree.Clear(); + _currentByKey.Clear(); + _previewByKey.Clear(); + + if (_currentPreview.CurrentTree is not null) + { + CurrentTree.Add(ConvertNode(_currentPreview.CurrentTree, RenameTreeSide.Current)); + } + + if (_currentPreview.IsSupported && _currentPreview.PreviewTree is not null) + { + PreviewTree.Add(ConvertNode(_currentPreview.PreviewTree, RenameTreeSide.Preview)); + UnsupportedReason = string.Empty; + IsPreviewSupported = true; + } + else + { + UnsupportedReason = _currentPreview.UnsupportedReason ?? "Невозможно построить предпросмотр."; + IsPreviewSupported = false; + } + } + + private void ExecuteRunRename() + { + if (!_currentPreview.IsSupported) + { + return; + } + + _logging.Info("запуск переименования сериала", "series-renamer"); + var result = _service.ExecutePreview(_currentPreview, RootFolderPath); + if (result.IsSuccess) + { + if (result.RootWasRenamed) + { + RootFolderPath = result.NewRootPath; + } + + _logging.Info("переименование завершено успешно", "series-renamer"); + RebuildPreview(); + } + else + { + _logging.Error($"ошибка переименования: {result.Error}", "series-renamer"); + } + } + + private RenameTreeNodeViewModel ConvertNode(SeriesNode node, RenameTreeSide side) + { + var vm = new RenameTreeNodeViewModel + { + Name = node.Name, + Kind = node.Kind, + IconGlyph = GetGlyph(node.Kind), + NodeKey = node.NodeKey, + Side = side, + IsExpanded = true + }; + vm.PropertyChanged += OnTreeNodePropertyChanged; + + if (side == RenameTreeSide.Current) + { + _currentByKey[node.NodeKey] = vm; + } + else + { + _previewByKey[node.NodeKey] = vm; + } + + foreach (var child in node.Children) + { + vm.Children.Add(ConvertNode(child, side)); + } + + return vm; + } + + private void OnTreeNodePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_isSynchronizingTrees || !IsPreviewSupported || sender is not RenameTreeNodeViewModel source) + { + return; + } + + if (e.PropertyName is not nameof(RenameTreeNodeViewModel.IsExpanded) and not nameof(RenameTreeNodeViewModel.IsSelected)) + { + return; + } + + var targetMap = source.Side == RenameTreeSide.Current ? _previewByKey : _currentByKey; + if (!targetMap.TryGetValue(source.NodeKey, out var target)) + { + return; + } + + try + { + _isSynchronizingTrees = true; + if (e.PropertyName == nameof(RenameTreeNodeViewModel.IsExpanded)) + { + target.IsExpanded = source.IsExpanded; + } + else if (e.PropertyName == nameof(RenameTreeNodeViewModel.IsSelected) && source.IsSelected) + { + target.IsSelected = true; + } + } + finally + { + _isSynchronizingTrees = false; + } + } + + private static string GetGlyph(string kind) + { + return kind switch + { + "Folder" => "\uE8B7", + "Video" => "\uEDA2", + "Sidecar" => "\uE8EA", + _ => "\uE8EA" + }; + } +} diff --git a/EmbyToolbox/ViewModels/ToastKind.cs b/EmbyToolbox/ViewModels/ToastKind.cs new file mode 100644 index 0000000..2669ec8 --- /dev/null +++ b/EmbyToolbox/ViewModels/ToastKind.cs @@ -0,0 +1,8 @@ +namespace EmbyToolbox.ViewModels; + +public enum ToastKind +{ + Success, + Warning, + Error, +} diff --git a/EmbyToolbox/ViewModels/TrackExtractionViewModel.cs b/EmbyToolbox/ViewModels/TrackExtractionViewModel.cs new file mode 100644 index 0000000..daf9319 --- /dev/null +++ b/EmbyToolbox/ViewModels/TrackExtractionViewModel.cs @@ -0,0 +1,832 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Threading; +using EmbyToolbox.Models; +using EmbyToolbox.Services; +using Microsoft.Win32; + +namespace EmbyToolbox.ViewModels; + +public sealed class TrackExtractionViewModel : INotifyPropertyChanged +{ + private readonly LoggingService _logging; + private readonly TrackExtractionService _service; + private readonly RecentPathService _recentPaths; + private readonly ExtractCommandBuilder _cmdBuilder = new(); + private readonly Dispatcher _dispatcher; + private readonly SemaphoreSlim _analyzeGate = new(1, 1); + + private CancellationTokenSource? _operationCts; + private bool _isAnalyzing; + private bool _isExtracting; + private double _overallProgressPercent; + private string _executionPhaseCaption = string.Empty; + private TrackExtractionRunOutcome _lastRunOutcome = TrackExtractionRunOutcome.None; + private TrackExtractionQueueItem? _selectedItem; + private bool _isDropHighlight; + private string _destinationFolderPath = string.Empty; + + public TrackExtractionViewModel(LoggingService logging, TrackExtractionService service, RecentPathService recentPaths) + { + _logging = logging; + _service = service; + _recentPaths = recentPaths; + _dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + + Items.CollectionChanged += OnItemsCollectionChanged; + + AddFilesCommand = new RelayCommand(ExecuteAddFiles, () => !IsBusy); + AddDirectoryCommand = new RelayCommand(ExecuteAddDirectory, () => !IsBusy); + ChooseDestinationFolderCommand = new RelayCommand(ExecuteChooseDestinationFolder, () => !IsBusy); + StartCommand = new RelayCommand(async () => await ExecuteStartAsync(), CanStart); + StopCommand = new RelayCommand(ExecuteStop, () => IsBusy); + ClearCommand = new RelayCommand(ExecuteClear, () => !IsBusy && Items.Count > 0); + + DestinationFolderPath = _recentPaths.GetNormalizedRememberedFolderPath(RecentPathScenario.TrackExtractDestination) + ?? string.Empty; + } + + public ObservableCollection Items { get; } = new(); + + public RelayCommand AddFilesCommand { get; } + public RelayCommand AddDirectoryCommand { get; } + public RelayCommand ChooseDestinationFolderCommand { get; } + public RelayCommand StartCommand { get; } + public RelayCommand StopCommand { get; } + public RelayCommand ClearCommand { get; } + + public string DestinationFolderPath + { + get => _destinationFolderPath; + set + { + if (_destinationFolderPath == value) + { + return; + } + + _destinationFolderPath = value; + OnPropertyChanged(); + RaiseCommandStates(); + } + } + + public TrackExtractionQueueItem? SelectedItem + { + get => _selectedItem; + set + { + if (ReferenceEquals(_selectedItem, value)) + { + return; + } + + _selectedItem = value; + OnPropertyChanged(); + } + } + + public bool IsDropHighlight + { + get => _isDropHighlight; + internal set + { + if (_isDropHighlight == value) + { + return; + } + + _isDropHighlight = value; + OnPropertyChanged(); + } + } + + public bool IsAnalyzingFiles + { + get => _isAnalyzing; + private set + { + if (_isAnalyzing == value) + { + return; + } + + _isAnalyzing = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsBusy)); + RaiseCommandStates(); + NotifyLongOperationHost(); + } + } + + public bool IsExtracting + { + get => _isExtracting; + private set + { + if (_isExtracting == value) + { + return; + } + + _isExtracting = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsBusy)); + RaiseCommandStates(); + NotifyLongOperationHost(); + } + } + + public bool IsBusy => IsAnalyzingFiles || IsExtracting; + + public double OverallProgressPercent + { + get => _overallProgressPercent; + private set + { + if (Math.Abs(_overallProgressPercent - value) < 0.0001) + { + return; + } + + _overallProgressPercent = value; + OnPropertyChanged(); + NotifyLongOperationHost(); + } + } + + public string ExecutionPhaseCaption + { + get => _executionPhaseCaption; + private set + { + if (_executionPhaseCaption == value) + { + return; + } + + _executionPhaseCaption = value; + OnPropertyChanged(); + NotifyLongOperationHost(); + } + } + + public TrackExtractionRunOutcome LastRunOutcome + { + get => _lastRunOutcome; + private set + { + if (_lastRunOutcome == value) + { + return; + } + + _lastRunOutcome = value; + OnPropertyChanged(); + NotifyLongOperationHost(); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void ApplyDroppedPaths(IReadOnlyList rawPaths) + { + if (IsBusy || rawPaths is null || rawPaths.Count == 0) + { + return; + } + + IsDropHighlight = false; + var discovered = new List(); + foreach (var raw in rawPaths) + { + try + { + var full = Path.GetFullPath(raw); + if (File.Exists(full)) + { + if (TrackExtractionFormats.IsSupportedPath(full)) + { + discovered.Add(full); + } + else + { + _logging.Warning($"извлечение дорожек (drop): пропуск неподдерживаемого файла: {full}", "tracks.extract"); + } + + continue; + } + + if (Directory.Exists(full)) + { + discovered.AddRange(TrackExtractionFormats.EnumerateMediaFilesRecursive(full)); + } + } + catch (Exception ex) + { + _logging.Warning($"извлечение дорожек (drop): не удалось обработать «{raw}»: {ex.Message}", "tracks.extract"); + } + } + + AddDiscoveredFiles(discovered); + } + + private void ExecuteAddFiles() + { + var dialog = new OpenFileDialog + { + Title = "Добавить файлы", + Filter = TrackExtractionFormats.BuildOpenFileFilter(), + Multiselect = true, + }; + + if (dialog.ShowDialog() != true || dialog.FileNames.Length == 0) + { + return; + } + + AddDiscoveredFiles(dialog.FileNames); + } + + private void ExecuteAddDirectory() + { + var dlg = new OpenFolderDialog { Title = "Добавить каталог с видео" }; + if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.FolderName)) + { + return; + } + + var list = TrackExtractionFormats.EnumerateMediaFilesRecursive(dlg.FolderName).ToList(); + if (list.Count == 0) + { + _logging.Warning($"извлечение дорожек: в каталоге не найдено .mkv/.mp4: {dlg.FolderName}", "tracks.extract"); + return; + } + + AddDiscoveredFiles(list); + } + + private void ExecuteChooseDestinationFolder() + { + var extra = string.IsNullOrWhiteSpace(DestinationFolderPath) ? null : DestinationFolderPath.Trim(); + var dialog = new OpenFolderDialog + { + Title = "Папка назначения (будет создан каталог extract)", + InitialDirectory = _recentPaths.GetInitialDirectory( + RecentPathScenario.TrackExtractDestination, + extraFolderFallbackBeforeDefault: extra), + }; + + if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName)) + { + return; + } + + try + { + DestinationFolderPath = Path.GetFullPath(dialog.FolderName.Trim()); + } + catch + { + DestinationFolderPath = dialog.FolderName.Trim(); + } + + _recentPaths.RememberChosenFolder(RecentPathScenario.TrackExtractDestination, DestinationFolderPath); + _logging.Info($"папка назначения извлечения: {DestinationFolderPath}", "tracks.extract"); + } + + private void AddDiscoveredFiles(IReadOnlyList paths) + { + if (paths.Count == 0) + { + return; + } + + var existing = new HashSet(Items.Select(i => i.FullPath), StringComparer.OrdinalIgnoreCase); + var newlyAdded = new List(); + foreach (var p in paths.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!File.Exists(p) || !TrackExtractionFormats.IsSupportedPath(p)) + { + continue; + } + + if (!existing.Add(p)) + { + continue; + } + + var item = new TrackExtractionQueueItem(p); + Items.Add(item); + newlyAdded.Add(item); + _logging.Info($"файл добавлен в извлечение дорожек: {Path.GetFileName(p)}", "tracks.extract"); + } + + RenumberRows(); + if (newlyAdded.Count > 0) + { + _ = RunAnalyzeGateAsync(newlyAdded); + } + + RaiseCommandStates(); + } + + private async Task RunAnalyzeGateAsync(List batch) + { + await _analyzeGate.WaitAsync(); + try + { + _operationCts = new CancellationTokenSource(); + var token = _operationCts.Token; + IsAnalyzingFiles = true; + LastRunOutcome = TrackExtractionRunOutcome.None; + OverallProgressPercent = 0; + ExecutionPhaseCaption = "Извлечение дорожек — анализ"; + var done = 0; + foreach (var item in batch) + { + token.ThrowIfCancellationRequested(); + if (!Items.Contains(item)) + { + continue; + } + + await AnalyzeOneItemAsync(item, token); + done++; + OverallProgressPercent = batch.Count > 0 ? 100.0 * done / batch.Count : 100; + } + } + catch (OperationCanceledException) + { + await _dispatcher.InvokeAsync(() => + { + foreach (var item in batch.Where(i => Items.Contains(i))) + { + if (item.Status == TrackExtractionStatuses.Analyzing) + { + item.Status = TrackExtractionStatuses.Cancelled; + item.Message = "Остановлено пользователем."; + } + } + }); + } + catch (Exception ex) + { + _logging.Error($"извлечение дорожек: сбой пакета анализа: {ex.Message}", "tracks.extract", ex); + await _dispatcher.InvokeAsync(() => + { + foreach (var item in batch.Where(i => Items.Contains(i) && i.Status == TrackExtractionStatuses.Analyzing)) + { + item.ApplyAnalysisError("Сбой анализа."); + } + }); + } + finally + { + IsAnalyzingFiles = false; + _operationCts?.Dispose(); + _operationCts = null; + OverallProgressPercent = 0; + ExecutionPhaseCaption = string.Empty; + _analyzeGate.Release(); + } + } + + private async Task AnalyzeOneItemAsync(TrackExtractionQueueItem item, CancellationToken token) + { + await _dispatcher.InvokeAsync(() => + { + item.Status = TrackExtractionStatuses.Analyzing; + item.Message = string.Empty; + }); + + try + { + var media = await _service.AnalyzeMediaAsync(item.FullPath, _logging, token).ConfigureAwait(false); + if (token.IsCancellationRequested) + { + return; + } + + await _dispatcher.InvokeAsync(() => + { + if (media is null) + { + item.ApplyAnalysisError("Не удалось разобрать вывод ffprobe."); + } + else + { + item.ApplyAnalysisOk(media); + } + }); + + await _dispatcher.InvokeAsync(() => + { + if (item.MediaAnalysis is not null) + { + _logging.Info($"анализ ffprobe завершён для «{item.FileName}»", "tracks.extract"); + } + }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + await _dispatcher.InvokeAsync(() => item.ApplyAnalysisError(ex.Message)); + _logging.Error($"ошибка анализа «{item.FileName}»: {ex.Message}", "tracks.extract"); + } + } + + private static bool TryNormalizeDestinationTrimmed(string? raw, out string normalizedTrimmed) + { + normalizedTrimmed = string.Empty; + if (string.IsNullOrWhiteSpace(raw)) + { + return false; + } + + try + { + normalizedTrimmed = Path.GetFullPath(raw.Trim()); + return true; + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + } + + private bool CanStart() + { + var destOk = TryNormalizeDestinationTrimmed(DestinationFolderPath, out _); + return !IsBusy && destOk && + Items.Any(i => i.Status == TrackExtractionStatuses.Ready); + } + + private async Task ExecuteStartAsync() + { + var ready = Items.Where(i => i.Status == TrackExtractionStatuses.Ready && i.MediaAnalysis is not null).ToList(); + if (ready.Count == 0) + { + return; + } + + if (!TryNormalizeDestinationTrimmed(DestinationFolderPath, out var destTrimmed)) + { + await _dispatcher.InvokeAsync(() => + { + foreach (var it in ready) + { + it.Status = TrackExtractionStatuses.Error; + it.Message = "Укажите корректную папку назначения."; + it.ProgressPercent = 100; + } + }); + + LastRunOutcome = TrackExtractionRunOutcome.Error; + RaiseCommandStates(); + return; + } + + _operationCts = new CancellationTokenSource(); + var token = _operationCts.Token; + IsExtracting = true; + LastRunOutcome = TrackExtractionRunOutcome.None; + var totalTracks = ready.Sum(i => Math.Max(i.TotalTracksToExtract, 0)); + if (totalTracks <= 0) + { + totalTracks = ready.Count; + } + + var doneTracks = 0; + var runHadErrors = false; + var cancelled = false; + ExecutionPhaseCaption = "Извлечение дорожек — извлечение"; + + try + { + var extractRoot = _service.PrepareExtractLayout(destTrimmed); + if (extractRoot is null) + { + runHadErrors = true; + await _dispatcher.InvokeAsync(() => + { + foreach (var it in ready) + { + it.Status = TrackExtractionStatuses.Error; + it.Message = + "Не удалось создать каталог extract. Проверьте папку назначения и доступ."; + it.ProgressPercent = 100; + } + }); + + _logging.Error("извлечение дорожек: не удалось создать extract в папке назначения", "tracks.extract"); + } + else + { + _logging.Info($"подготовлен каталог извлечения: {extractRoot}", "tracks.extract"); + + var audioDir = Path.Combine(extractRoot, "audio"); + var subsDir = Path.Combine(extractRoot, "subtitles"); + var attDir = Path.Combine(extractRoot, "attachments"); + + foreach (var item in ready) + { + token.ThrowIfCancellationRequested(); + await _dispatcher.InvokeAsync(() => + { + item.Status = TrackExtractionStatuses.Working; + item.ProgressPercent = 0; + item.Message = string.Empty; + }); + + var media = item.MediaAnalysis!; + var totalInFile = Math.Max(item.TotalTracksToExtract, 1); + var doneInFile = 0; + var fileHadErrors = false; + + if (item.TotalTracksToExtract <= 0) + { + doneTracks++; + await _dispatcher.InvokeAsync(() => + { + item.ProgressPercent = 100; + item.Status = TrackExtractionStatuses.Done; + item.Message = "Дорожек не найдено."; + OverallProgressPercent = 100.0 * doneTracks / Math.Max(totalTracks, 1); + }); + continue; + } + + var stem = ExtractCommandBuilder.SanitizeSourceFileStem(Path.GetFileNameWithoutExtension(item.FullPath)); + var a = 0; + foreach (var s in media.AudioStreams) + { + token.ThrowIfCancellationRequested(); + a++; + var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, a); + var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(audioDir, desired); + var dest = Path.Combine(audioDir, allocated); + if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false)) + { + fileHadErrors = true; + runHadErrors = true; + } + + doneInFile++; + doneTracks++; + UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks); + } + + var su = 0; + foreach (var s in media.SubtitleStreams) + { + token.ThrowIfCancellationRequested(); + su++; + var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, su); + var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(subsDir, desired); + var dest = Path.Combine(subsDir, allocated); + if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false)) + { + fileHadErrors = true; + runHadErrors = true; + } + + doneInFile++; + doneTracks++; + UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks); + } + + var att = 0; + foreach (var s in media.AllStreams.Where(x => x.Kind == MediaStreamKind.Attachment)) + { + token.ThrowIfCancellationRequested(); + att++; + var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, att); + var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(attDir, desired); + var dest = Path.Combine(attDir, allocated); + if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false)) + { + fileHadErrors = true; + runHadErrors = true; + } + + doneInFile++; + doneTracks++; + UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks); + } + + await _dispatcher.InvokeAsync(() => + { + if (item.Status == TrackExtractionStatuses.Cancelled) + { + return; + } + + if (fileHadErrors) + { + item.Status = TrackExtractionStatuses.Error; + item.ProgressPercent = 100; + } + else + { + item.Status = TrackExtractionStatuses.Done; + item.Message = "Готово."; + item.ProgressPercent = 100; + } + }); + } + } + } + catch (OperationCanceledException) + { + cancelled = true; + LastRunOutcome = TrackExtractionRunOutcome.Cancelled; + foreach (var item in Items.Where(i => i.Status == TrackExtractionStatuses.Working)) + { + await _dispatcher.InvokeAsync(() => + { + item.Status = TrackExtractionStatuses.Cancelled; + item.Message = "Остановлено пользователем."; + }); + } + } + catch (Exception ex) + { + runHadErrors = true; + _logging.Error($"извлечение дорожек: неперехваченная ошибка: {ex.Message}", "tracks.extract", ex); + await _dispatcher.InvokeAsync(() => + { + foreach (var item in Items.Where(i => i.Status == TrackExtractionStatuses.Working)) + { + item.Status = TrackExtractionStatuses.Error; + item.Message = ex.Message.Length > 200 ? ex.Message[..200] + "…" : ex.Message; + } + }); + } + finally + { + await _dispatcher.InvokeAsync(() => + { + IsExtracting = false; + _operationCts?.Dispose(); + _operationCts = null; + OverallProgressPercent = 0; + ExecutionPhaseCaption = string.Empty; + + if (!cancelled) + { + LastRunOutcome = runHadErrors + ? TrackExtractionRunOutcome.Error + : TrackExtractionRunOutcome.Success; + } + + RaiseCommandStates(); + }); + } + } + + private void UpdateRowAndOverall( + TrackExtractionQueueItem item, + int doneInFile, + int totalInFile, + int doneTracks, + int totalTracks) + { + var rowPct = 100.0 * doneInFile / totalInFile; + var overall = 100.0 * doneTracks / Math.Max(totalTracks, 1); + _dispatcher.InvokeAsync(() => + { + item.ProgressPercent = rowPct; + OverallProgressPercent = overall; + }); + } + + private async Task ExtractStreamAsync( + TrackExtractionQueueItem item, + MediaStreamInfo stream, + string destinationPath, + CancellationToken token) + { + try + { + var dir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var args = _cmdBuilder.BuildFfmpegArgumentList(item.FullPath, stream, destinationPath); + var (ok, err) = await _service.RunExtractProcessAsync(args, token).ConfigureAwait(false); + if (ok) + { + await _dispatcher.InvokeAsync(() => + { + var shortName = Path.GetFileName(destinationPath); + item.Message = $"Извлечено: {shortName}"; + }); + _logging.Info($"извлечена дорожка [{stream.Kind}] #{stream.Index} → {destinationPath}", "tracks.extract"); + return true; + } + + var msg = string.IsNullOrWhiteSpace(err) ? $"ffmpeg ошибка дорожки {stream.Index}" : err.Trim(); + await _dispatcher.InvokeAsync(() => + { + item.Status = TrackExtractionStatuses.Error; + item.Message = msg.Length > 200 ? msg[..200] + "…" : msg; + }); + _logging.Error($"ошибка извлечения дорожки #{stream.Index} из «{item.FileName}»: {msg}", "tracks.extract"); + return false; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + await _dispatcher.InvokeAsync(() => + { + item.Status = TrackExtractionStatuses.Error; + item.Message = ex.Message; + }); + _logging.Error($"ошибка извлечения «{item.FileName}»: {ex.Message}", "tracks.extract", ex); + return false; + } + } + + private void ExecuteStop() + { + try + { + _operationCts?.Cancel(); + } + catch + { + // ignore + } + } + + private void ExecuteClear() + { + if (IsBusy) + { + return; + } + + Items.Clear(); + SelectedItem = null; + LastRunOutcome = TrackExtractionRunOutcome.None; + RenumberRows(); + RaiseCommandStates(); + } + + private void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RenumberRows(); + RaiseCommandStates(); + } + + private void RenumberRows() + { + var n = 1; + foreach (var i in Items) + { + i.RowNumber = n++; + } + } + + private void RaiseCommandStates() + { + AddFilesCommand.RaiseCanExecuteChanged(); + AddDirectoryCommand.RaiseCanExecuteChanged(); + ChooseDestinationFolderCommand.RaiseCanExecuteChanged(); + StartCommand.RaiseCanExecuteChanged(); + StopCommand.RaiseCanExecuteChanged(); + ClearCommand.RaiseCanExecuteChanged(); + } + + private void NotifyLongOperationHost() + { + if (_dispatcher.CheckAccess()) + { + OnPropertyChanged(nameof(IsBusy)); + } + else + { + _dispatcher.BeginInvoke(() => OnPropertyChanged(nameof(IsBusy))); + } + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/EmbyToolbox/ViewModels/TrackSettingsRowViewModel.cs b/EmbyToolbox/ViewModels/TrackSettingsRowViewModel.cs new file mode 100644 index 0000000..e92a883 --- /dev/null +++ b/EmbyToolbox/ViewModels/TrackSettingsRowViewModel.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.Linq; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using EmbyToolbox.Models; +using EmbyToolbox.Services; + +namespace EmbyToolbox.ViewModels; + +public interface ITrackPlanPreviewHost +{ + void RecalculatePlanPreview(); + void OnTrackDefaultEnabled(TrackSettingsRowViewModel row); + void ValidateDefaultConflicts(); +} + +public sealed class TrackSettingsRowViewModel : INotifyPropertyChanged +{ + private readonly ITrackPlanPreviewHost _parent; + private readonly TrackOverrideEntry _entry; + private readonly MediaStreamInfo? _embeddedStream; + private string _details; + private TrackActionKind _action; + private bool _hasDefaultConflict; + + public TrackSettingsRowViewModel( + ITrackPlanPreviewHost parent, + TrackOverrideEntry entry, + int displayIndex, + MediaStreamInfo? embedded, + string? targetContainer) + { + _parent = parent; + _entry = entry; + _embeddedStream = embedded; + _action = entry.Action; + IndexDisplay = displayIndex; + if (entry.Source == SourceKind.External) + { + SourceDisplay = "Внешняя"; + if (entry.StreamKind == MediaStreamKind.Subtitle) + { + entry.Language = "rus"; + } + else if (string.IsNullOrEmpty(entry.Language)) + { + entry.Language = "rus"; + } + } + else + { + SourceDisplay = "Встроенная"; + } + + TypeDisplay = entry.StreamKind switch + { + MediaStreamKind.Video => "Video", + MediaStreamKind.Audio => "Audio", + MediaStreamKind.Subtitle => "Subtitle", + MediaStreamKind.Attachment => "Attachment", + MediaStreamKind.Data => "Data", + _ => "?" + }; + + Codec = entry.StreamKind == MediaStreamKind.Subtitle && embedded is not null && SubtitleCodecRules.IsTeletext(embedded.CodecName) + ? "dvb_teletext" + : embedded?.CodecName ?? (entry.Source == SourceKind.External + ? (entry.StreamKind == MediaStreamKind.Attachment + ? "font" + : entry.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(entry.ExternalStreamCodec) + ? entry.ExternalStreamCodec + : (System.IO.Path.GetExtension(entry.ExternalPath ?? string.Empty) is { Length: > 0 } e ? e : "?")) + : "?"); + + _details = BuildDetails(entry, embedded, targetContainer); + + ValidActions = new List(GetValidActions(entry)); + if (!ValidActions.Contains(_action) && ValidActions.Count > 0) + { + _action = ValidActions[0]; + _entry.Action = _action; + } + } + + public TrackOverrideEntry DataModel => _entry; + + public int IndexDisplay { get; } + + public string SourceDisplay { get; } + public string TypeDisplay { get; } + public string Codec { get; } + public string Details => _details; + + /// Обновить подсказку Details при смене целевого контейнера (teletext + MKV). + public void RefreshSubtitleDetails(string? targetContainer) + { + if (_entry is not { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle }) + { + return; + } + + var next = BuildDetails(_entry, _embeddedStream, targetContainer); + if (_details == next) + { + return; + } + + _details = next; + OnPropertyChanged(nameof(Details)); + } + + /// Встроенная teletext-субдорожка для выделения строки (MKV copy не поддерживается). + public bool IsTeletextSubtitle => + _entry.Source == SourceKind.Embedded + && _entry.StreamKind == MediaStreamKind.Subtitle + && SubtitleCodecRules.IsTeletext(_embeddedStream?.CodecName); + public IList ValidActions { get; } + + public TrackActionKind Action + { + get => _action; + set + { + if (_action == value) + { + return; + } + + _action = value; + _entry.Action = value; + if (value == TrackActionKind.Remove) + { + _entry.Default = false; + OnPropertyChanged(nameof(Default)); + } + + OnPropertyChanged(); + OnPropertyChanged(nameof(IsAudioBitrateVisible)); + OnPropertyChanged(nameof(IsDefaultEnabled)); + _parent.ValidateDefaultConflicts(); + _parent.RecalculatePlanPreview(); + } + } + + public string? Language + { + get => _entry.Language; + set + { + if (_entry.Language == value) + { + return; + } + + _entry.Language = value; + OnPropertyChanged(); + _parent.RecalculatePlanPreview(); + } + } + + public string? Title + { + get => _entry.Title; + set + { + if (_entry.Title == value) + { + return; + } + + _entry.Title = value; + OnPropertyChanged(); + _parent.RecalculatePlanPreview(); + } + } + + public bool? Default + { + get => _entry.Default; + set + { + if (_entry.Default == value) + { + return; + } + + _entry.Default = value; + OnPropertyChanged(); + if (value is true) + { + _parent.OnTrackDefaultEnabled(this); + } + + _parent.ValidateDefaultConflicts(); + _parent.RecalculatePlanPreview(); + } + } + + public bool IsDefaultEnabled => + (_entry.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle) && _action != TrackActionKind.Remove; + + public bool HasDefaultConflict + { + get => _hasDefaultConflict; + set + { + if (_hasDefaultConflict == value) + { + return; + } + + _hasDefaultConflict = value; + OnPropertyChanged(); + } + } + + public string? AudioBitrateKbps + { + get => _entry.AudioBitrateKbps; + set + { + if (_entry.AudioBitrateKbps == value) + { + return; + } + + _entry.AudioBitrateKbps = value; + OnPropertyChanged(); + _parent.RecalculatePlanPreview(); + } + } + + public bool IsAudioBitrateVisible => + _entry.StreamKind == MediaStreamKind.Audio + && ((_entry.Source == SourceKind.Embedded && _action == TrackActionKind.Convert) + || (_entry.Source == SourceKind.External && _action == TrackActionKind.Add + && !string.IsNullOrWhiteSpace(_entry.AudioBitrateKbps))); + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private static string FormatBps(long? bps) => + bps is { } b ? $"{(b + 500) / 1000} kbps" : "?"; + + private static string BuildDetails(TrackOverrideEntry entry, MediaStreamInfo? embedded, string? targetContainer) + { + if (entry.Source == SourceKind.External) + { + if (!string.IsNullOrWhiteSpace(entry.ExternalStreamDetails)) + { + return entry.ExternalStreamDetails; + } + + return entry.ExternalPath ?? string.Empty; + } + + if (embedded is { Kind: MediaStreamKind.Subtitle } sub) + { + if (SubtitleCodecRules.IsTeletext(sub.CodecName) && SubtitleCodecRules.TargetsMkv(targetContainer)) + { + return "unsupported for MKV copy"; + } + + return sub.SubtitleFormat ?? sub.CodecName; + } + + return embedded switch + { + { Kind: MediaStreamKind.Video } v => + $"{v.Width?.ToString() ?? "?"}x{v.Height?.ToString() ?? "?"}; {(v.FrameRate is { } fr ? fr.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) : "?")} fps; {v.PixelFormat ?? "?"}", + { Kind: MediaStreamKind.Audio } a => $"{a.Channels} ch; {a.SampleRateHz} Hz; {FormatBps(a.BitRateBps)}", + _ => string.Empty + }; + } + + private static IReadOnlyList GetValidActions(TrackOverrideEntry e) + { + if (e.Source == SourceKind.External) + { + return [TrackActionKind.Add, TrackActionKind.Remove]; + } + + if (e.StreamKind is MediaStreamKind.Data) + { + return [TrackActionKind.Remove, TrackActionKind.Keep]; + } + + if (e.StreamKind is MediaStreamKind.Attachment) + { + return [TrackActionKind.Remove, TrackActionKind.Keep]; + } + + if (e.StreamKind is MediaStreamKind.Video) + { + return [TrackActionKind.Keep, TrackActionKind.Convert, TrackActionKind.Remove]; + } + + if (e.StreamKind is MediaStreamKind.Subtitle) + { + return [TrackActionKind.Keep, TrackActionKind.Convert, TrackActionKind.Remove]; + } + + if (e.StreamKind is MediaStreamKind.Audio) + { + return [TrackActionKind.Keep, TrackActionKind.Convert, TrackActionKind.Remove]; + } + + return [TrackActionKind.Keep, TrackActionKind.Remove]; + } +} diff --git a/EmbyToolbox/ViewModels/VideoInfoViewModel.cs b/EmbyToolbox/ViewModels/VideoInfoViewModel.cs new file mode 100644 index 0000000..92d747c --- /dev/null +++ b/EmbyToolbox/ViewModels/VideoInfoViewModel.cs @@ -0,0 +1,711 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Linq; +using System.Windows; +using System.Windows.Input; +using System.IO; +using EmbyToolbox.Models; +using EmbyToolbox.Services; +using Microsoft.Win32; + +namespace EmbyToolbox.ViewModels; + +public sealed class VideoInfoViewModel : INotifyPropertyChanged +{ + private readonly FfprobeService _ffprobeService; + private readonly LoggingService _logging; + private readonly RecentPathService _recentPaths; + private readonly SidecarDiscoveryService _sidecarDiscoveryService; + private readonly VideoInfoSummaryService _summaryService; + + private string _selectedFilePath = string.Empty; + private string _analysisStateText = string.Empty; + private string _errorMessage = string.Empty; + private string _rawJson = string.Empty; + private string _summaryText = string.Empty; + private bool _isBusy; + private bool _isVideoInfoDropHighlight; + private int _selectedSubTabIndex; + + public VideoInfoViewModel( + FfprobeService ffprobeService, + LoggingService logging, + RecentPathService recentPaths, + SidecarDiscoveryService sidecarDiscoveryService, + VideoInfoSummaryService summaryService) + { + _ffprobeService = ffprobeService; + _logging = logging; + _recentPaths = recentPaths; + _sidecarDiscoveryService = sidecarDiscoveryService; + _summaryService = summaryService; + + SelectFileCommand = new RelayCommand(ExecuteSelectFile); + SelectSummaryFilesCommand = new RelayCommand(ExecuteSelectSummaryFiles); + ExpandAllCommand = new RelayCommand(ExecuteExpandAll); + CollapseAllCommand = new RelayCommand(ExecuteCollapseAll); + CopyJsonCommand = new RelayCommand(ExecuteCopyJson, () => !string.IsNullOrWhiteSpace(_rawJson)); + SaveJsonCommand = new RelayCommand(ExecuteSaveJson, () => !string.IsNullOrWhiteSpace(_rawJson)); + CopySummaryCommand = new RelayCommand(ExecuteCopySummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован"); + SaveSummaryCommand = new RelayCommand(ExecuteSaveSummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован"); + CopyNodeValueCommand = new RelayCommand(ExecuteCopyNodeValue, CanCopyNodeValue); + CopyNodeLineCommand = new RelayCommand(ExecuteCopyNodeLine, CanCopyNodeLine); + CopyNodeWithChildrenCommand = new RelayCommand(ExecuteCopyNodeWithChildren, CanCopyNodeWithChildren); + CopyNodePathCommand = new RelayCommand(ExecuteCopyNodePath, CanCopyNodePath); + } + + public ObservableCollection JsonNodes { get; } = new(); + + public ICommand SelectFileCommand { get; } + public ICommand SelectSummaryFilesCommand { get; } + + public ICommand ExpandAllCommand { get; } + + public ICommand CollapseAllCommand { get; } + + public ICommand CopyJsonCommand { get; } + + public ICommand SaveJsonCommand { get; } + public ICommand CopySummaryCommand { get; } + public ICommand SaveSummaryCommand { get; } + + public ICommand CopyNodeValueCommand { get; } + + public ICommand CopyNodeLineCommand { get; } + + public ICommand CopyNodeWithChildrenCommand { get; } + + public ICommand CopyNodePathCommand { get; } + + public string SelectedFilePath + { + get => _selectedFilePath; + private set + { + if (_selectedFilePath == value) + { + return; + } + + _selectedFilePath = value; + OnPropertyChanged(); + } + } + + public string AnalysisStateText + { + get => _analysisStateText; + private set + { + if (_analysisStateText == value) + { + return; + } + + _analysisStateText = value; + OnPropertyChanged(); + } + } + + public string ErrorMessage + { + get => _errorMessage; + private set + { + if (_errorMessage == value) + { + return; + } + + _errorMessage = value; + OnPropertyChanged(); + } + } + + public bool IsBusy + { + get => _isBusy; + private set + { + if (_isBusy == value) + { + return; + } + + _isBusy = value; + OnPropertyChanged(); + } + } + + public int SelectedSubTabIndex + { + get => _selectedSubTabIndex; + set + { + if (_selectedSubTabIndex == value) + { + return; + } + + _selectedSubTabIndex = value; + OnPropertyChanged(); + } + } + + public string SummaryText + { + get => _summaryText; + private set + { + if (_summaryText == value) + { + return; + } + + _summaryText = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasSummaryData)); + } + } + + public bool HasSummaryData => !string.IsNullOrWhiteSpace(_summaryText); + + public bool IsVideoInfoDropHighlight + { + get => _isVideoInfoDropHighlight; + internal set + { + if (_isVideoInfoDropHighlight == value) + { + return; + } + + _isVideoInfoDropHighlight = value; + OnPropertyChanged(); + } + } + + /// На вкладке summary анализирует все поддерживаемые файлы; на detailed — только первый. + public void ApplyDroppedPathsAndAnalyze(string[]? paths) + { + if (paths is null || paths.Length == 0) + { + return; + } + + IsVideoInfoDropHighlight = false; + + if (IsBusy) + { + _logging.Warning("video-info (drop): анализ уже выполняется — повторите после завершения", "video-info"); + return; + } + + _ = SelectedSubTabIndex == 0 + ? ApplyDroppedSummaryInternalAsync(paths) + : ApplyDroppedDetailedInternalAsync(paths); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private async void ExecuteSelectFile() + { + var dialog = new OpenFileDialog + { + Title = "Выберите видеофайл", + Filter = SupportedVideoFormats.BuildOpenFileDialogFilter(), + InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.VideoInfoOpenFile), + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + if (!SupportedVideoFormats.IsSupportedVideoFile(dialog.FileName)) + { + _logging.Warning($"video-info: формат не поддерживается: {dialog.FileName}", "video-info"); + return; + } + + _recentPaths.RememberChosenFiles(RecentPathScenario.VideoInfoOpenFile, [dialog.FileName]); + + SelectedFilePath = dialog.FileName; + _logging.Info($"выбран файл: {dialog.FileName}", "video-info"); + _logging.Debug("запуск ffprobe", "video-info.ffprobe"); + await AnalyzeAsync(); + } + + private async void ExecuteSelectSummaryFiles() + { + var dialog = new OpenFileDialog + { + Title = "Выберите видеофайлы", + Filter = SupportedVideoFormats.BuildOpenFileDialogFilter(), + InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.VideoInfoOpenFile), + Multiselect = true + }; + + if (dialog.ShowDialog() != true || dialog.FileNames.Length == 0) + { + return; + } + + _recentPaths.RememberChosenFiles(RecentPathScenario.VideoInfoOpenFile, dialog.FileNames); + await AnalyzeSummaryFilesAsync(dialog.FileNames); + } + + private async Task ApplyDroppedDetailedInternalAsync(string[] paths) + { + try + { + foreach (var raw in paths.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + string full; + try + { + full = Path.GetFullPath(raw); + } + catch (Exception ex) + { + _logging.Warning($"video-info (drop): путь «{raw}»: {ex.Message}", "video-info"); + continue; + } + + if (!File.Exists(full)) + { + continue; + } + + if (!SupportedVideoFormats.IsSupportedVideoFile(full)) + { + _logging.Warning($"video-info (drop): формат не поддерживается: {full}", "video-info"); + continue; + } + + _recentPaths.RememberChosenFiles(RecentPathScenario.VideoInfoOpenFile, [full]); + SelectedFilePath = full; + _logging.Info($"video-info (drop): запуск анализа {full}", "video-info"); + await AnalyzeAsync(); + return; + } + + _logging.Warning("video-info (drop): ни один поддерживаемый видеофайл не найден", "video-info"); + } + catch (Exception ex) + { + _logging.Error($"video-info (drop): {ex.Message}", "video-info", ex); + } + } + + private async Task AnalyzeAsync() + { + JsonNodes.Clear(); + ErrorMessage = string.Empty; + _rawJson = string.Empty; + RaiseCommandStates(); + + if (string.IsNullOrWhiteSpace(SelectedFilePath)) + { + AnalysisStateText = string.Empty; + return; + } + + IsBusy = true; + AnalysisStateText = "Анализ файла..."; + + var result = await _ffprobeService.AnalyzeAsync(SelectedFilePath); + IsBusy = false; + + if (!result.IsSuccess) + { + AnalysisStateText = "Ошибка анализа"; + ErrorMessage = result.Error; + _logging.Error($"ffprobe: {result.Error}", "video-info.ffprobe", command: result.Command, stdout: result.StdOut, stderr: result.StdErr); + RaiseCommandStates(); + return; + } + + try + { + _rawJson = result.Json; + BuildTree(_rawJson); + AnalysisStateText = "Готово"; + RaiseCommandStates(); + _logging.Info($"ffprobe завершен: {Path.GetFileName(SelectedFilePath)}", "video-info.ffprobe", command: result.Command, stdout: result.StdOut, stderr: result.StdErr); + } + catch (Exception ex) + { + AnalysisStateText = "Ошибка анализа"; + ErrorMessage = $"Не удалось разобрать JSON ffprobe: {ex.Message}"; + _logging.Error($"parse json: {ex.Message}", "video-info.json", ex); + RaiseCommandStates(); + } + } + + private async Task ApplyDroppedSummaryInternalAsync(string[] paths) + { + try + { + await AnalyzeSummaryFilesAsync(paths); + } + catch (Exception ex) + { + _logging.Error($"video-info summary (drop): {ex.Message}", "video-info", ex); + } + } + + private async Task AnalyzeSummaryFilesAsync(IEnumerable rawPaths) + { + SummaryText = string.Empty; + ErrorMessage = string.Empty; + AnalysisStateText = "Анализ файла..."; + + var supportedFiles = rawPaths + .Select( + p => + { + try + { + return Path.GetFullPath(p); + } + catch + { + return string.Empty; + } + }) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Where(File.Exists) + .Where(SupportedVideoFormats.IsSupportedVideoFile) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (supportedFiles.Count == 0) + { + AnalysisStateText = string.Empty; + return; + } + + IsBusy = true; + var all = new List(supportedFiles.Count * 8); + foreach (var file in supportedFiles) + { + var probe = await _ffprobeService.AnalyzeAsync(file); + if (!probe.IsSuccess) + { + all.Add($"{file}{Environment.NewLine}Ошибка анализа: {probe.Error}"); + all.Add(string.Empty); + continue; + } + + var media = MediaAnalysisParser.TryParse(probe.Json); + if (media is null) + { + all.Add($"{file}{Environment.NewLine}Ошибка разбора ffprobe JSON"); + all.Add(string.Empty); + continue; + } + + var sidecarResult = await _sidecarDiscoveryService.DiscoverAsync(file, _ffprobeService).ConfigureAwait(true); + var summarySidecars = new SidecarAnalysisResult(file, sidecarResult.Sidecars, sidecarResult.ExternalAudioFiles); + all.Add(file); + all.Add(_summaryService.BuildSummary(media, summarySidecars)); + all.Add(string.Empty); + } + + IsBusy = false; + SummaryText = string.Join(Environment.NewLine, all).Trim(); + AnalysisStateText = "Готово"; + RaiseCommandStates(); + } + + private void BuildTree(string json) + { + using var doc = JsonDocument.Parse(json); + JsonNodes.Clear(); + + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in doc.RootElement.EnumerateObject()) + { + JsonNodes.Add(CreateNode(property.Name, property.Value, null)); + } + } + else + { + JsonNodes.Add(CreateNode("root", doc.RootElement, null)); + } + } + + private static JsonTreeNodeViewModel CreateNode(string name, JsonElement element, JsonTreeNodeViewModel? parent) + { + var node = new JsonTreeNodeViewModel(name, GetPreviewValue(element), element.GetRawText(), parent); + + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in element.EnumerateObject()) + { + node.Children.Add(CreateNode(prop.Name, prop.Value, node)); + } + break; + case JsonValueKind.Array: + var index = 0; + foreach (var item in element.EnumerateArray()) + { + node.Children.Add(CreateNode($"[{index}]", item, node)); + index++; + } + break; + } + + return node; + } + + private static string GetPreviewValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => "{...}", + JsonValueKind.Array => "[...]", + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + _ => element.GetRawText() + }; + } + + private void ExecuteExpandAll() + { + SetExpandedState(JsonNodes, true); + } + + private void ExecuteCollapseAll() + { + SetExpandedState(JsonNodes, false); + } + + private static void SetExpandedState(IEnumerable nodes, bool isExpanded) + { + foreach (var node in nodes) + { + node.IsExpanded = isExpanded; + SetExpandedState(node.Children, isExpanded); + } + } + + private void ExecuteCopyJson() + { + if (string.IsNullOrWhiteSpace(_rawJson)) + { + return; + } + + Clipboard.SetText(_rawJson); + _logging.Info("JSON скопирован в буфер обмена", "video-info.copy"); + } + + private void ExecuteSaveJson() + { + if (string.IsNullOrWhiteSpace(_rawJson)) + { + return; + } + + var defaultName = string.IsNullOrWhiteSpace(SelectedFilePath) + ? "ffprobe.json" + : $"{Path.GetFileNameWithoutExtension(SelectedFilePath)}.ffprobe.json"; + + var dialog = new SaveFileDialog + { + Title = "Сохранить JSON ffprobe", + Filter = "JSON (*.json)|*.json|Все файлы|*.*", + FileName = defaultName, + InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder), + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + File.WriteAllText(dialog.FileName, _rawJson); + _recentPaths.RememberChosenFolder( + RecentPathScenario.SettingsOutputFolder, + Path.GetDirectoryName(dialog.FileName) ?? dialog.FileName); + _logging.Info($"JSON сохранен: {dialog.FileName}", "video-info.save"); + } + + private void ExecuteCopySummary() + { + if (string.IsNullOrWhiteSpace(SummaryText) || SummaryText == "Файл не проанализирован") + { + return; + } + + Clipboard.SetText(SummaryText); + _logging.Info("summary скопирован в буфер обмена", "video-info.copy"); + } + + private void ExecuteSaveSummary() + { + if (string.IsNullOrWhiteSpace(SummaryText) || SummaryText == "Файл не проанализирован") + { + return; + } + + var defaultName = string.IsNullOrWhiteSpace(SelectedFilePath) + ? "video-summary.txt" + : $"{Path.GetFileNameWithoutExtension(SelectedFilePath)}.summary.txt"; + + var initialDir = string.IsNullOrWhiteSpace(SelectedFilePath) + ? _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder) + : Path.GetDirectoryName(SelectedFilePath) ?? _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder); + + var dialog = new SaveFileDialog + { + Title = "Сохранить summary", + Filter = "Text (*.txt)|*.txt|Все файлы|*.*", + FileName = defaultName, + InitialDirectory = initialDir + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + File.WriteAllText(dialog.FileName, SummaryText); + _recentPaths.RememberChosenFolder( + RecentPathScenario.SettingsOutputFolder, + Path.GetDirectoryName(dialog.FileName) ?? dialog.FileName); + _logging.Info($"summary сохранен: {dialog.FileName}", "video-info.save"); + } + + private void RaiseCommandStates() + { + (CopyJsonCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (SaveJsonCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (CopySummaryCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (SaveSummaryCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (CopyNodeValueCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (CopyNodeLineCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (CopyNodeWithChildrenCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (CopyNodePathCommand as RelayCommand)?.RaiseCanExecuteChanged(); + } + + private static JsonTreeNodeViewModel? AsNode(object? parameter) + { + return parameter as JsonTreeNodeViewModel; + } + + private bool CanCopyNodeValue(object? parameter) + { + var node = AsNode(parameter); + return node is not null && node.Children.Count == 0; + } + + private bool CanCopyNodeLine(object? parameter) + { + return AsNode(parameter) is not null; + } + + private bool CanCopyNodeWithChildren(object? parameter) + { + return AsNode(parameter) is not null; + } + + private bool CanCopyNodePath(object? parameter) + { + return AsNode(parameter) is not null; + } + + private void ExecuteCopyNodeValue(object? parameter) + { + var node = AsNode(parameter); + if (node is null || node.Children.Count > 0) + { + return; + } + + Clipboard.SetText(node.Value); + _logging.Info("узел JSON скопирован", "video-info.copy"); + } + + private void ExecuteCopyNodeLine(object? parameter) + { + var node = AsNode(parameter); + if (node is null) + { + return; + } + + Clipboard.SetText($"{node.Name}: {node.Value}"); + _logging.Info("узел JSON скопирован", "video-info.copy"); + } + + private void ExecuteCopyNodeWithChildren(object? parameter) + { + var node = AsNode(parameter); + if (node is null) + { + return; + } + + var formatted = FormatJsonOrFallback(node.SubtreeJson); + Clipboard.SetText(formatted); + _logging.Info("узел JSON скопирован", "video-info.copy"); + } + + private void ExecuteCopyNodePath(object? parameter) + { + var node = AsNode(parameter); + if (node is null) + { + return; + } + + Clipboard.SetText(BuildNodePath(node)); + _logging.Info("узел JSON скопирован", "video-info.copy"); + } + + private static string BuildNodePath(JsonTreeNodeViewModel node) + { + if (node.Parent is null) + { + return node.Name; + } + + var parentPath = BuildNodePath(node.Parent); + if (node.Name.StartsWith("[", StringComparison.Ordinal)) + { + return $"{parentPath}{node.Name}"; + } + + return string.IsNullOrWhiteSpace(parentPath) ? node.Name : $"{parentPath}.{node.Name}"; + } + + private static string FormatJsonOrFallback(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return json; + } + } +} + diff --git a/EmbyToolbox/Views/AddFilesOptionsDialog.xaml b/EmbyToolbox/Views/AddFilesOptionsDialog.xaml new file mode 100644 index 0000000..cc40eaa --- /dev/null +++ b/EmbyToolbox/Views/AddFilesOptionsDialog.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmbyToolbox/Views/ConversionView.xaml.cs b/EmbyToolbox/Views/ConversionView.xaml.cs new file mode 100644 index 0000000..61ad921 --- /dev/null +++ b/EmbyToolbox/Views/ConversionView.xaml.cs @@ -0,0 +1,40 @@ +using System.Windows; +using System.Windows.Controls; +using EmbyToolbox.ViewModels; + +namespace EmbyToolbox.Views; + +public partial class ConversionView +{ + public ConversionView() + { + InitializeComponent(); + Loaded += (_, _) => RefreshCopyErrorMenuFromGrid(); + } + + private void QueueDataGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) => + RefreshCopyErrorMenuFromGrid(); + + private void QueueContextMenu_Opened(object sender, RoutedEventArgs e) + { + if (sender is ContextMenu cm && cm.PlacementTarget is DataGrid dg) + { + PushCopyErrorMenuVisibility(dg.SelectedItems); + } + else + { + RefreshCopyErrorMenuFromGrid(); + } + } + + private void RefreshCopyErrorMenuFromGrid() => + PushCopyErrorMenuVisibility(QueueDataGrid.SelectedItems); + + private void PushCopyErrorMenuVisibility(System.Collections.IList selected) + { + if (DataContext is ConversionViewModel vm) + { + vm.RefreshCopyQueueItemErrorMenuState(selected); + } + } +} diff --git a/EmbyToolbox/Views/FileConversionSettingsWindow.xaml b/EmbyToolbox/Views/FileConversionSettingsWindow.xaml new file mode 100644 index 0000000..aaeb76c --- /dev/null +++ b/EmbyToolbox/Views/FileConversionSettingsWindow.xaml @@ -0,0 +1,648 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmbyToolbox/Views/LogsView.xaml.cs b/EmbyToolbox/Views/LogsView.xaml.cs new file mode 100644 index 0000000..51f536d --- /dev/null +++ b/EmbyToolbox/Views/LogsView.xaml.cs @@ -0,0 +1,327 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using System.Windows.Threading; +using EmbyToolbox.Services; +using EmbyToolbox.ViewModels; + +namespace EmbyToolbox.Views; + +/// +/// Журнал в : цвет строки — ресурсные кисти и +/// на Run +/// (не new Run(...) { Foreground = Brushes...). +/// +public partial class LogsView +{ + /// WPF: должно быть < 1_000_000 DIP. + private const double FlowDocumentSafePageWidth = 999_999.0; + + private const double BottomEpsilonPx = 4.0; + + private readonly FlowDocument _document; + private LogsViewModel? _vm; + private ScrollViewer? _scrollViewer; + private ScrollChangedEventHandler? _scrollChangedHandler; + + private bool _stickToBottom = true; + + public LogsView() + { + InitializeComponent(); + _document = new FlowDocument + { + PagePadding = new Thickness(0), + Background = Brushes.Transparent, + Foreground = Brushes.Black, + FontFamily = new FontFamily("Consolas,Cascadia Mono"), + FontSize = 13, + PageWidth = FlowDocumentSafePageWidth, + TextAlignment = TextAlignment.Left, + }; + LogRichTextBox.Document = _document; + + Loaded += (_, _) => AttachBindings(); + Unloaded += (_, _) => DetachBindingsCore(); + DataContextChanged += (_, _) => AttachBindings(); + } + + private void AttachBindings() + { + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, AttachCore); + } + + private void AttachCore() + { + if (!IsLoaded) + { + return; + } + + if (LogRichTextBox is null) + { + return; + } + + if (DataContext is not LogsViewModel vm) + { + DetachBindingsCore(); + return; + } + + if (ReferenceEquals(_vm, vm)) + { + Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(TryHookScrollWatcher)); + return; + } + + DetachBindingsCore(); + _vm = vm; + _vm.PropertyChanged += OnVmPropertyChanged; + _vm.UiEntries.CollectionChanged += OnUiEntriesChanged; + RebuildFull(); + Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(() => + { + TryHookScrollWatcher(); + _stickToBottom = true; + ScrollToEnd(); + })); + } + + private void DetachBindingsCore() + { + UnhookScrollWatcher(); + + _document.Blocks.Clear(); + + if (_vm is null) + { + return; + } + + _vm.PropertyChanged -= OnVmPropertyChanged; + _vm.UiEntries.CollectionChanged -= OnUiEntriesChanged; + _vm = null; + } + + private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(LogsViewModel.ScrollPulse)) + { + _stickToBottom = true; + ScrollToEnd(); + } + } + + private void OnUiEntriesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_vm is null) + { + return; + } + + switch (e.Action) + { + case NotifyCollectionChangedAction.Reset: + _document.Blocks.Clear(); + _stickToBottom = true; + ScrollIfSticky(); + break; + + case NotifyCollectionChangedAction.Add: + if (TryAppendAdds(e)) + { + ScrollIfSticky(); + return; + } + + goto default; + + case NotifyCollectionChangedAction.Remove: + if (TryRemoveFromHead(e)) + { + ScrollIfSticky(); + return; + } + + goto default; + + default: + RebuildFull(); + ScrollIfSticky(); + break; + } + } + + private bool TryAppendAdds(NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is null || e.NewStartingIndex != _vm!.UiEntries.Count - e.NewItems.Count) + { + return false; + } + + if (_document.Blocks.Count != _vm.UiEntries.Count - e.NewItems.Count) + { + return false; + } + + foreach (LogEntryViewModel item in e.NewItems) + { + _document.Blocks.Add(CreateParagraph(item)); + } + + return true; + } + + private bool TryRemoveFromHead(NotifyCollectionChangedEventArgs e) + { + if (e.OldItems is null + || e.OldStartingIndex != 0 + || _document.Blocks.Count != _vm!.UiEntries.Count + e.OldItems.Count) + { + return false; + } + + for (var i = 0; i < e.OldItems.Count; i++) + { + var first = _document.Blocks.FirstBlock; + if (first is null) + { + return false; + } + + _document.Blocks.Remove(first); + } + + return true; + } + + private void RebuildFull() + { + if (_vm is null) + { + return; + } + + _document.Blocks.Clear(); + foreach (var entry in _vm.UiEntries) + { + _document.Blocks.Add(CreateParagraph(entry)); + } + } + + private static Paragraph CreateParagraph(LogEntryViewModel entry) + { + var run = new Run(entry.DisplayText); + run.SetResourceReference(TextElement.ForegroundProperty, LogLevelToForegroundResourceKey(entry.Level)); + return new Paragraph(run) + { + LineHeight = double.NaN, + Margin = new Thickness(0), + }; + } + + /// Ключи кистей в (не задаём через Brushes напрямую). + private static object LogLevelToForegroundResourceKey(LogLevel level) => + level switch + { + LogLevel.Debug => "LogDebugBrush", + LogLevel.Info => "LogInfoBrush", + LogLevel.Warning => "LogWarningBrush", + LogLevel.Error => "LogErrorBrush", + _ => "LogInfoBrush", + }; + + private void TryHookScrollWatcher() + { + if (LogRichTextBox is null) + { + return; + } + + var sv = FindDescendantScrollViewer(LogRichTextBox); + if (sv is null) + { + return; + } + + if (ReferenceEquals(sv, _scrollViewer)) + { + RefreshStickyFromScroller(); + return; + } + + UnhookScrollWatcher(); + _scrollViewer = sv; + _scrollChangedHandler = OnScrollViewerScrollChanged; + _scrollViewer.ScrollChanged += _scrollChangedHandler; + RefreshStickyFromScroller(); + } + + private void UnhookScrollWatcher() + { + if (_scrollViewer is not null && _scrollChangedHandler is not null) + { + _scrollViewer.ScrollChanged -= _scrollChangedHandler; + } + + _scrollViewer = null; + _scrollChangedHandler = null; + } + + private void OnScrollViewerScrollChanged(object? _, ScrollChangedEventArgs __) => + RefreshStickyFromScroller(); + + private void RefreshStickyFromScroller() + { + if (_scrollViewer is null) + { + return; + } + + var sv = _scrollViewer; + _stickToBottom = sv.ScrollableHeight <= 0 + || sv.VerticalOffset >= sv.ScrollableHeight - BottomEpsilonPx; + } + + private void ScrollIfSticky() + { + if (!_stickToBottom) + { + return; + } + + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(ScrollToEnd)); + } + + private void ScrollToEnd() + { + TryHookScrollWatcher(); + LogRichTextBox.CaretPosition = LogRichTextBox.Document.ContentEnd; + LogRichTextBox.ScrollToEnd(); + RefreshStickyFromScroller(); + } + + private static ScrollViewer? FindDescendantScrollViewer(DependencyObject root) + { + if (root is ScrollViewer viewer) + { + return viewer; + } + + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) + { + var child = VisualTreeHelper.GetChild(root, i); + var nested = FindDescendantScrollViewer(child); + if (nested is not null) + { + return nested; + } + } + + return null; + } +} diff --git a/EmbyToolbox/Views/MergeView.xaml b/EmbyToolbox/Views/MergeView.xaml new file mode 100644 index 0000000..8c4be2e --- /dev/null +++ b/EmbyToolbox/Views/MergeView.xaml @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmbyToolbox/Views/MergeView.xaml.cs b/EmbyToolbox/Views/MergeView.xaml.cs new file mode 100644 index 0000000..8922782 --- /dev/null +++ b/EmbyToolbox/Views/MergeView.xaml.cs @@ -0,0 +1,41 @@ +using System.Windows.Controls; +using System.Windows.Input; +using EmbyToolbox.Models; +using EmbyToolbox.ViewModels; + +namespace EmbyToolbox.Views; + +public partial class MergeView +{ + public MergeView() + { + InitializeComponent(); + } + + private void FilesGrid_OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (DataContext is not MergeViewModel vm || sender is not DataGrid grid) + { + return; + } + + var selected = grid.SelectedItems.OfType().ToList(); + vm.UpdateSelectedItems(selected); + } + + private void FilesGrid_OnPreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Delete || DataContext is not MergeViewModel vm) + { + return; + } + + if (!vm.RemoveFromListCommand.CanExecute(null)) + { + return; + } + + vm.RemoveFromListCommand.Execute(null); + e.Handled = true; + } +} diff --git a/EmbyToolbox/Views/StyleTestTableRow.cs b/EmbyToolbox/Views/StyleTestTableRow.cs new file mode 100644 index 0000000..786a4a0 --- /dev/null +++ b/EmbyToolbox/Views/StyleTestTableRow.cs @@ -0,0 +1,10 @@ +namespace EmbyToolbox.Views; + +public sealed class StyleTestTableRow +{ + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public string Codec { get; set; } = ""; + public string Language { get; set; } = ""; + public string Status { get; set; } = ""; +} diff --git a/EmbyToolbox/Views/TrackExtractionView.xaml b/EmbyToolbox/Views/TrackExtractionView.xaml new file mode 100644 index 0000000..6a404ea --- /dev/null +++ b/EmbyToolbox/Views/TrackExtractionView.xaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmbyToolbox/Views/TrackExtractionView.xaml.cs b/EmbyToolbox/Views/TrackExtractionView.xaml.cs new file mode 100644 index 0000000..ae89c98 --- /dev/null +++ b/EmbyToolbox/Views/TrackExtractionView.xaml.cs @@ -0,0 +1,9 @@ +namespace EmbyToolbox.Views; + +public partial class TrackExtractionView +{ + public TrackExtractionView() + { + InitializeComponent(); + } +}