Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
6264b487fe
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
_build_temp/
|
||||
_buildcheck/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
34
EmbyToolbox.sln
Normal file
34
EmbyToolbox.sln
Normal file
@ -0,0 +1,34 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmbyToolbox", "EmbyToolbox\EmbyToolbox.csproj", "{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{386DE6D3-887D-41FF-A6CD-C60DFA1FB05F}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
13
EmbyToolbox/App.xaml
Normal file
13
EmbyToolbox/App.xaml
Normal file
@ -0,0 +1,13 @@
|
||||
<Application x:Class="EmbyToolbox.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/UiColors.xaml" />
|
||||
<ResourceDictionary Source="Themes/UiComponentStyles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
16
EmbyToolbox/App.xaml.cs
Normal file
16
EmbyToolbox/App.xaml.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System.Windows;
|
||||
using EmbyToolbox.Interop;
|
||||
using EmbyToolbox.Services;
|
||||
|
||||
namespace EmbyToolbox;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
_ = AppUserModelIdRegistration.TryRegister(NotificationService.ToastAppUserModelId);
|
||||
base.OnStartup(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
EmbyToolbox/AssemblyInfo.cs
Normal file
10
EmbyToolbox/AssemblyInfo.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
@ -0,0 +1,47 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
/// <summary>Всплывает <see cref="Button.ContextMenu"/> по левому клику вместо ПКМ (split-menu кнопка).</summary>
|
||||
public static class ContextMenuOpenOnButtonClickBehavior
|
||||
{
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled",
|
||||
typeof(bool),
|
||||
typeof(ContextMenuOpenOnButtonClickBehavior),
|
||||
new PropertyMetadata(false, OnIsEnabledChanged));
|
||||
|
||||
public static void SetIsEnabled(Button obj, bool value) => obj.SetValue(IsEnabledProperty, value);
|
||||
|
||||
public static bool GetIsEnabled(Button obj) => (bool)obj.GetValue(IsEnabledProperty);
|
||||
|
||||
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not Button btn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
btn.Click -= OnButtonClick;
|
||||
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
btn.Click += OnButtonClick;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button btn || btn.ContextMenu is not ContextMenu menu)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
menu.PlacementTarget = btn;
|
||||
menu.Placement = PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
102
EmbyToolbox/Behaviors/ConversionQueueDropTargetBehavior.cs
Normal file
102
EmbyToolbox/Behaviors/ConversionQueueDropTargetBehavior.cs
Normal file
@ -0,0 +1,102 @@
|
||||
using System.Windows;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class ConversionQueueDropTargetBehavior
|
||||
{
|
||||
public static readonly DependencyProperty IsDropTargetEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsDropTargetEnabled",
|
||||
typeof(bool),
|
||||
typeof(ConversionQueueDropTargetBehavior),
|
||||
new PropertyMetadata(false, OnIsDropTargetEnabledChanged));
|
||||
|
||||
public static void SetIsDropTargetEnabled(DependencyObject d, bool value) => d.SetValue(IsDropTargetEnabledProperty, value);
|
||||
public static bool GetIsDropTargetEnabled(DependencyObject d) => (bool)d.GetValue(IsDropTargetEnabledProperty);
|
||||
|
||||
private static void OnIsDropTargetEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not UIElement el)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.OldValue is true)
|
||||
{
|
||||
el.PreviewDragOver -= OnPreviewDragOver;
|
||||
el.DragLeave -= OnDragLeave;
|
||||
el.Drop -= OnDrop;
|
||||
}
|
||||
|
||||
if (e.NewValue is not true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
el.AllowDrop = true;
|
||||
el.PreviewDragOver += OnPreviewDragOver;
|
||||
el.DragLeave += OnDragLeave;
|
||||
el.Drop += OnDrop;
|
||||
}
|
||||
|
||||
private static void OnDragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (fe.DataContext is ConversionViewModel vm)
|
||||
{
|
||||
vm.IsQueueDropHighlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (fe.DataContext is not ConversionViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||
{
|
||||
e.Effects = DragDropEffects.Copy;
|
||||
e.Handled = true;
|
||||
vm.IsQueueDropHighlight = true;
|
||||
return;
|
||||
}
|
||||
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsQueueDropHighlight = false;
|
||||
}
|
||||
|
||||
private static void OnDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (fe.DataContext is not ConversionViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsQueueDropHighlight = false;
|
||||
|
||||
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vm.ProcessPathsDroppedOnQueue(paths);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
127
EmbyToolbox/Behaviors/DataGridAutoScrollSelectionBehavior.cs
Normal file
127
EmbyToolbox/Behaviors/DataGridAutoScrollSelectionBehavior.cs
Normal file
@ -0,0 +1,127 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class DataGridAutoScrollSelectionBehavior
|
||||
{
|
||||
private static readonly DependencyProperty SuppressAutoScrollUntilProperty = DependencyProperty.RegisterAttached(
|
||||
"SuppressAutoScrollUntil",
|
||||
typeof(DateTime),
|
||||
typeof(DataGridAutoScrollSelectionBehavior),
|
||||
new PropertyMetadata(DateTime.MinValue));
|
||||
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled",
|
||||
typeof(bool),
|
||||
typeof(DataGridAutoScrollSelectionBehavior),
|
||||
new PropertyMetadata(false, OnChanged));
|
||||
|
||||
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
|
||||
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
|
||||
|
||||
private static void OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not DataGrid dg)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.OldValue is true)
|
||||
{
|
||||
dg.SelectionChanged -= OnSelectionChanged;
|
||||
dg.PreviewMouseLeftButtonDown -= OnPreviewMouseLeftButtonDown;
|
||||
dg.PreviewKeyDown -= OnPreviewKeyDown;
|
||||
dg.PreviewMouseWheel -= OnPreviewMouseWheel;
|
||||
}
|
||||
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
dg.SelectionChanged += OnSelectionChanged;
|
||||
dg.PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown;
|
||||
dg.PreviewKeyDown += OnPreviewKeyDown;
|
||||
dg.PreviewMouseWheel += OnPreviewMouseWheel;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not DataGrid dg)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var modifiers = Keyboard.Modifiers;
|
||||
if ((modifiers & (ModifierKeys.Shift | ModifierKeys.Control)) != ModifierKeys.None)
|
||||
{
|
||||
SetSuppressAutoScrollUntil(dg, DateTime.UtcNow.AddMilliseconds(350));
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (sender is not DataGrid dg)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.LeftShift or Key.RightShift or Key.LeftCtrl or Key.RightCtrl)
|
||||
{
|
||||
SetSuppressAutoScrollUntil(dg, DateTime.UtcNow.AddMilliseconds(350));
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
if (sender is not DataGrid dg)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе SelectionChanged + ScrollIntoView «дёргают» прокрутку колёсиком.
|
||||
SetSuppressAutoScrollUntil(dg, DateTime.UtcNow.AddMilliseconds(500));
|
||||
}
|
||||
|
||||
private static void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not DataGrid dg || dg.SelectedItem is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldSuppressForUserRangeSelection(dg))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dg.Dispatcher.BeginInvoke(
|
||||
DispatcherPriority.Background,
|
||||
() =>
|
||||
{
|
||||
if (dg.SelectedItem is not null)
|
||||
{
|
||||
dg.ScrollIntoView(dg.SelectedItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool ShouldSuppressForUserRangeSelection(DataGrid dg)
|
||||
{
|
||||
var modifiers = Keyboard.Modifiers;
|
||||
if ((modifiers & (ModifierKeys.Shift | ModifierKeys.Control)) != ModifierKeys.None)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateTime.UtcNow <= GetSuppressAutoScrollUntil(dg);
|
||||
}
|
||||
|
||||
private static DateTime GetSuppressAutoScrollUntil(DependencyObject obj) =>
|
||||
(DateTime)obj.GetValue(SuppressAutoScrollUntilProperty);
|
||||
|
||||
private static void SetSuppressAutoScrollUntil(DependencyObject obj, DateTime value) =>
|
||||
obj.SetValue(SuppressAutoScrollUntilProperty, value);
|
||||
}
|
||||
|
||||
83
EmbyToolbox/Behaviors/DataGridRightClickSelectionBehavior.cs
Normal file
83
EmbyToolbox/Behaviors/DataGridRightClickSelectionBehavior.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class DataGridRightClickSelectionBehavior
|
||||
{
|
||||
public static readonly DependencyProperty IsEnabledProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"IsEnabled",
|
||||
typeof(bool),
|
||||
typeof(DataGridRightClickSelectionBehavior),
|
||||
new PropertyMetadata(false, OnIsEnabledChanged));
|
||||
|
||||
public static bool GetIsEnabled(DependencyObject obj) => (bool)obj.GetValue(IsEnabledProperty);
|
||||
|
||||
public static void SetIsEnabled(DependencyObject obj, bool value) => obj.SetValue(IsEnabledProperty, value);
|
||||
|
||||
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not DataGrid grid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
grid.PreviewMouseRightButtonDown -= OnPreviewMouseRightButtonDown;
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
grid.PreviewMouseRightButtonDown += OnPreviewMouseRightButtonDown;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not DataGrid grid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var row = FindAncestor<DataGridRow>(e.OriginalSource as DependencyObject);
|
||||
if (row?.Item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Если клик по уже выделенной строке — сохраняем множественное выделение.
|
||||
if (row.IsSelected)
|
||||
{
|
||||
row.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Если клик по невыделенной строке — выделяем только ее.
|
||||
grid.SelectedItems.Clear();
|
||||
row.IsSelected = true;
|
||||
row.Focus();
|
||||
}
|
||||
|
||||
private static T? FindAncestor<T>(DependencyObject? start)
|
||||
where T : DependencyObject
|
||||
{
|
||||
var current = start;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is T found)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
|
||||
current = current switch
|
||||
{
|
||||
Visual v => VisualTreeHelper.GetParent(v),
|
||||
FrameworkContentElement fce => fce.Parent,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
105
EmbyToolbox/Behaviors/DataGridRowDoubleClickCommandBehavior.cs
Normal file
105
EmbyToolbox/Behaviors/DataGridRowDoubleClickCommandBehavior.cs
Normal file
@ -0,0 +1,105 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
/// <summary>Двойной клик по строке DataGrid: вызов ICommand с аргументом <see cref="ConversionQueueItem"/>.</summary>
|
||||
public static class DataGridRowDoubleClickCommandBehavior
|
||||
{
|
||||
public static readonly DependencyProperty CommandProperty = DependencyProperty.RegisterAttached(
|
||||
"Command",
|
||||
typeof(ICommand),
|
||||
typeof(DataGridRowDoubleClickCommandBehavior),
|
||||
new PropertyMetadata(null, OnCommandChanged));
|
||||
|
||||
public static ICommand? GetCommand(DependencyObject d) => (ICommand?)d.GetValue(CommandProperty);
|
||||
public static void SetCommand(DependencyObject d, ICommand? v) => d.SetValue(CommandProperty, v);
|
||||
|
||||
private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not DataGrid dg)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.OldValue is not null)
|
||||
{
|
||||
dg.MouseDoubleClick -= OnMouseDoubleClick;
|
||||
}
|
||||
|
||||
if (e.NewValue is not null)
|
||||
{
|
||||
dg.MouseDoubleClick += OnMouseDoubleClick;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not DataGrid dg)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsInCombo((DependencyObject)e.OriginalSource))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var row = GetRow((DependencyObject)e.OriginalSource);
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.Item is not ConversionQueueItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cmd = GetCommand(dg);
|
||||
if (cmd is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.CanExecute(item))
|
||||
{
|
||||
cmd.Execute(item);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static DataGridRow? GetRow(DependencyObject? current)
|
||||
{
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is DataGridRow r)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
|
||||
current = VisualTreeHelper.GetParent(current);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsInCombo(DependencyObject? d)
|
||||
{
|
||||
while (d is not null)
|
||||
{
|
||||
if (d is ComboBox)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
d = VisualTreeHelper.GetParent(d);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
EmbyToolbox/Behaviors/FileDropBehavior.cs
Normal file
72
EmbyToolbox/Behaviors/FileDropBehavior.cs
Normal file
@ -0,0 +1,72 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class FileDropBehavior
|
||||
{
|
||||
public static readonly DependencyProperty DropCommandProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"DropCommand",
|
||||
typeof(RelayCommand),
|
||||
typeof(FileDropBehavior),
|
||||
new PropertyMetadata(null, OnDropCommandChanged));
|
||||
|
||||
public static void SetDropCommand(DependencyObject element, RelayCommand? value)
|
||||
{
|
||||
element.SetValue(DropCommandProperty, value);
|
||||
}
|
||||
|
||||
public static RelayCommand? GetDropCommand(DependencyObject element)
|
||||
{
|
||||
return (RelayCommand?)element.GetValue(DropCommandProperty);
|
||||
}
|
||||
|
||||
private static void OnDropCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not UIElement element)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
element.AllowDrop = e.NewValue is not null;
|
||||
element.PreviewDragOver -= OnPreviewDragOver;
|
||||
element.Drop -= OnDrop;
|
||||
|
||||
if (e.NewValue is not null)
|
||||
{
|
||||
element.PreviewDragOver += OnPreviewDragOver;
|
||||
element.Drop += OnDrop;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||
{
|
||||
e.Effects = DragDropEffects.Copy;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not DependencyObject d)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var command = GetDropCommand(d);
|
||||
if (command is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Data.GetData(DataFormats.FileDrop) is string[] paths && command.CanExecute(paths))
|
||||
{
|
||||
command.Execute(paths);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
121
EmbyToolbox/Behaviors/ListBoxAutoScrollBehavior.cs
Normal file
121
EmbyToolbox/Behaviors/ListBoxAutoScrollBehavior.cs
Normal file
@ -0,0 +1,121 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class ListBoxAutoScrollBehavior
|
||||
{
|
||||
public static readonly DependencyProperty AutoScrollToEndProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"AutoScrollToEnd",
|
||||
typeof(bool),
|
||||
typeof(ListBoxAutoScrollBehavior),
|
||||
new PropertyMetadata(false, OnAutoScrollToEndChanged));
|
||||
|
||||
public static bool GetAutoScrollToEnd(DependencyObject obj)
|
||||
{
|
||||
return (bool)obj.GetValue(AutoScrollToEndProperty);
|
||||
}
|
||||
|
||||
public static void SetAutoScrollToEnd(DependencyObject obj, bool value)
|
||||
{
|
||||
obj.SetValue(AutoScrollToEndProperty, value);
|
||||
}
|
||||
|
||||
private static void OnAutoScrollToEndChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not ListBox listBox)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((bool)e.NewValue)
|
||||
{
|
||||
listBox.Loaded += ListBoxOnLoaded;
|
||||
}
|
||||
else
|
||||
{
|
||||
listBox.Loaded -= ListBoxOnLoaded;
|
||||
UnhookCollection(listBox);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ListBoxOnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ListBox listBox)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HookCollection(listBox);
|
||||
ScrollToLast(listBox);
|
||||
}
|
||||
|
||||
private static void HookCollection(ListBox listBox)
|
||||
{
|
||||
if (listBox.ItemsSource is INotifyCollectionChanged collection)
|
||||
{
|
||||
collection.CollectionChanged -= OnCollectionChanged;
|
||||
collection.CollectionChanged += OnCollectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private static void UnhookCollection(ListBox listBox)
|
||||
{
|
||||
if (listBox.ItemsSource is INotifyCollectionChanged collection)
|
||||
{
|
||||
collection.CollectionChanged -= OnCollectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not INotifyCollectionChanged collection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Window window in Application.Current.Windows)
|
||||
{
|
||||
var listBox = FindListBoxForCollection(window, collection);
|
||||
if (listBox is not null)
|
||||
{
|
||||
ScrollToLast(listBox);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ListBox? FindListBoxForCollection(DependencyObject root, INotifyCollectionChanged collection)
|
||||
{
|
||||
if (root is ListBox listBox && ReferenceEquals(listBox.ItemsSource, collection))
|
||||
{
|
||||
return listBox;
|
||||
}
|
||||
|
||||
var childrenCount = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
|
||||
for (var i = 0; i < childrenCount; i++)
|
||||
{
|
||||
var child = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
|
||||
var found = FindListBoxForCollection(child, collection);
|
||||
if (found is not null)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ScrollToLast(ListBox listBox)
|
||||
{
|
||||
if (listBox.Items.Count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var last = listBox.Items[listBox.Items.Count - 1];
|
||||
listBox.ScrollIntoView(last);
|
||||
}
|
||||
}
|
||||
97
EmbyToolbox/Behaviors/MergeDropTargetBehavior.cs
Normal file
97
EmbyToolbox/Behaviors/MergeDropTargetBehavior.cs
Normal file
@ -0,0 +1,97 @@
|
||||
using System.Windows;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class MergeDropTargetBehavior
|
||||
{
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled",
|
||||
typeof(bool),
|
||||
typeof(MergeDropTargetBehavior),
|
||||
new PropertyMetadata(false, OnIsEnabledChanged));
|
||||
|
||||
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
|
||||
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
|
||||
|
||||
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not FrameworkElement fe)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fe.AllowDrop = false;
|
||||
fe.PreviewDragOver -= OnPreviewDragOver;
|
||||
fe.PreviewDragLeave -= OnPreviewDragLeave;
|
||||
fe.PreviewDrop -= OnPreviewDrop;
|
||||
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
fe.AllowDrop = true;
|
||||
fe.PreviewDragOver += OnPreviewDragOver;
|
||||
fe.PreviewDragLeave += OnPreviewDragLeave;
|
||||
fe.PreviewDrop += OnPreviewDrop;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not MergeViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (vm.IsRunning)
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsMergeDropHighlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||
{
|
||||
e.Effects = DragDropEffects.Copy;
|
||||
e.Handled = true;
|
||||
vm.IsMergeDropHighlight = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsMergeDropHighlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not MergeViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsMergeDropHighlight = false;
|
||||
}
|
||||
|
||||
private static void OnPreviewDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not MergeViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsMergeDropHighlight = false;
|
||||
if (vm.IsRunning)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vm.ApplyDroppedPaths(paths);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
85
EmbyToolbox/Behaviors/SeriesRenamerDropTargetBehavior.cs
Normal file
85
EmbyToolbox/Behaviors/SeriesRenamerDropTargetBehavior.cs
Normal file
@ -0,0 +1,85 @@
|
||||
using System.Windows;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class SeriesRenamerDropTargetBehavior
|
||||
{
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled",
|
||||
typeof(bool),
|
||||
typeof(SeriesRenamerDropTargetBehavior),
|
||||
new PropertyMetadata(false, OnIsEnabledChanged));
|
||||
|
||||
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
|
||||
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
|
||||
|
||||
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not FrameworkElement fe)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fe.AllowDrop = false;
|
||||
fe.PreviewDragOver -= OnPreviewDragOver;
|
||||
fe.PreviewDragLeave -= OnPreviewDragLeave;
|
||||
fe.PreviewDrop -= OnPreviewDrop;
|
||||
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
fe.AllowDrop = true;
|
||||
fe.PreviewDragOver += OnPreviewDragOver;
|
||||
fe.PreviewDragLeave += OnPreviewDragLeave;
|
||||
fe.PreviewDrop += OnPreviewDrop;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not SeriesRenamerViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||
{
|
||||
e.Effects = DragDropEffects.Copy;
|
||||
e.Handled = true;
|
||||
vm.IsRootTreeDragOver = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsRootTreeDragOver = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not SeriesRenamerViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsRootTreeDragOver = false;
|
||||
}
|
||||
|
||||
private static void OnPreviewDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not SeriesRenamerViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsRootTreeDragOver = false;
|
||||
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vm.ApplyDroppedPaths(paths);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
50
EmbyToolbox/Behaviors/TextBoxAutoScrollBehavior.cs
Normal file
50
EmbyToolbox/Behaviors/TextBoxAutoScrollBehavior.cs
Normal file
@ -0,0 +1,50 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class TextBoxAutoScrollBehavior
|
||||
{
|
||||
public static readonly DependencyProperty AutoScrollToEndProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"AutoScrollToEnd",
|
||||
typeof(bool),
|
||||
typeof(TextBoxAutoScrollBehavior),
|
||||
new PropertyMetadata(false, OnAutoScrollToEndChanged));
|
||||
|
||||
public static bool GetAutoScrollToEnd(DependencyObject obj)
|
||||
{
|
||||
return (bool)obj.GetValue(AutoScrollToEndProperty);
|
||||
}
|
||||
|
||||
public static void SetAutoScrollToEnd(DependencyObject obj, bool value)
|
||||
{
|
||||
obj.SetValue(AutoScrollToEndProperty, value);
|
||||
}
|
||||
|
||||
private static void OnAutoScrollToEndChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not TextBox textBox)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((bool)e.NewValue)
|
||||
{
|
||||
textBox.TextChanged += OnTextBoxTextChanged;
|
||||
textBox.ScrollToEnd();
|
||||
}
|
||||
else
|
||||
{
|
||||
textBox.TextChanged -= OnTextBoxTextChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnTextBoxTextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox textBox)
|
||||
{
|
||||
textBox.ScrollToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
132
EmbyToolbox/Behaviors/TrackExtractionDropTargetBehavior.cs
Normal file
132
EmbyToolbox/Behaviors/TrackExtractionDropTargetBehavior.cs
Normal file
@ -0,0 +1,132 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using EmbyToolbox.Services;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class TrackExtractionDropTargetBehavior
|
||||
{
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled",
|
||||
typeof(bool),
|
||||
typeof(TrackExtractionDropTargetBehavior),
|
||||
new PropertyMetadata(false, OnIsEnabledChanged));
|
||||
|
||||
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
|
||||
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
|
||||
|
||||
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not FrameworkElement fe)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fe.AllowDrop = false;
|
||||
fe.PreviewDragOver -= OnPreviewDragOver;
|
||||
fe.PreviewDragLeave -= OnPreviewDragLeave;
|
||||
fe.PreviewDrop -= OnPreviewDrop;
|
||||
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
fe.AllowDrop = true;
|
||||
fe.PreviewDragOver += OnPreviewDragOver;
|
||||
fe.PreviewDragLeave += OnPreviewDragLeave;
|
||||
fe.PreviewDrop += OnPreviewDrop;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not TrackExtractionViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (vm.IsBusy)
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsDropHighlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsDropHighlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var paths = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||
if (!HasSupportedDrop(paths))
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsDropHighlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
e.Effects = DragDropEffects.Copy;
|
||||
e.Handled = true;
|
||||
vm.IsDropHighlight = true;
|
||||
}
|
||||
|
||||
private static bool HasSupportedDrop(string[] paths)
|
||||
{
|
||||
if (paths is null || paths.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var raw in paths)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(raw);
|
||||
if (File.Exists(full) && TrackExtractionFormats.IsSupportedPath(full))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Directory.Exists(full))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not TrackExtractionViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsDropHighlight = false;
|
||||
}
|
||||
|
||||
private static void OnPreviewDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not TrackExtractionViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsDropHighlight = false;
|
||||
if (vm.IsBusy || e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vm.ApplyDroppedPaths(paths);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
166
EmbyToolbox/Behaviors/TreeViewScrollSyncBehavior.cs
Normal file
166
EmbyToolbox/Behaviors/TreeViewScrollSyncBehavior.cs
Normal file
@ -0,0 +1,166 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class TreeViewScrollSyncBehavior
|
||||
{
|
||||
public static readonly DependencyProperty SyncGroupProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"SyncGroup",
|
||||
typeof(string),
|
||||
typeof(TreeViewScrollSyncBehavior),
|
||||
new PropertyMetadata(string.Empty, OnSyncGroupChanged));
|
||||
|
||||
private static readonly Dictionary<string, List<WeakReference<TreeView>>> Groups = new(StringComparer.Ordinal);
|
||||
private static bool _syncing;
|
||||
|
||||
public static string GetSyncGroup(DependencyObject obj) => (string)obj.GetValue(SyncGroupProperty);
|
||||
public static void SetSyncGroup(DependencyObject obj, string value) => obj.SetValue(SyncGroupProperty, value);
|
||||
|
||||
private static void OnSyncGroupChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not TreeView treeView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
treeView.Loaded -= TreeViewOnLoaded;
|
||||
treeView.Unloaded -= TreeViewOnUnloaded;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(e.NewValue as string))
|
||||
{
|
||||
treeView.Loaded += TreeViewOnLoaded;
|
||||
treeView.Unloaded += TreeViewOnUnloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TreeViewOnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not TreeView tree)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var group = GetSyncGroup(tree);
|
||||
if (string.IsNullOrWhiteSpace(group))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Groups.TryGetValue(group, out var list))
|
||||
{
|
||||
list = new List<WeakReference<TreeView>>();
|
||||
Groups[group] = list;
|
||||
}
|
||||
|
||||
list.Add(new WeakReference<TreeView>(tree));
|
||||
|
||||
var scroll = FindScrollViewer(tree);
|
||||
if (scroll is not null)
|
||||
{
|
||||
scroll.ScrollChanged -= OnScrollChanged;
|
||||
scroll.ScrollChanged += OnScrollChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TreeViewOnUnloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not TreeView tree)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scroll = FindScrollViewer(tree);
|
||||
if (scroll is not null)
|
||||
{
|
||||
scroll.ScrollChanged -= OnScrollChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnScrollChanged(object sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
if (_syncing || e.VerticalChange == 0 || sender is not ScrollViewer sourceScroll)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceTree = FindAncestor<TreeView>(sourceScroll);
|
||||
if (sourceTree is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var group = GetSyncGroup(sourceTree);
|
||||
if (string.IsNullOrWhiteSpace(group) || !Groups.TryGetValue(group, out var members))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_syncing = true;
|
||||
foreach (var weak in members.ToList())
|
||||
{
|
||||
if (!weak.TryGetTarget(out var targetTree))
|
||||
{
|
||||
members.Remove(weak);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(targetTree, sourceTree))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetScroll = FindScrollViewer(targetTree);
|
||||
if (targetScroll is not null)
|
||||
{
|
||||
targetScroll.ScrollToVerticalOffset(sourceScroll.VerticalOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ScrollViewer? FindScrollViewer(DependencyObject root)
|
||||
{
|
||||
if (root is ScrollViewer sv)
|
||||
{
|
||||
return sv;
|
||||
}
|
||||
|
||||
var count = VisualTreeHelper.GetChildrenCount(root);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(root, i);
|
||||
var found = FindScrollViewer(child);
|
||||
if (found is not null)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static T? FindAncestor<T>(DependencyObject node) where T : DependencyObject
|
||||
{
|
||||
var current = node;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is T t)
|
||||
{
|
||||
return t;
|
||||
}
|
||||
|
||||
current = VisualTreeHelper.GetParent(current);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
92
EmbyToolbox/Behaviors/VideoInfoDropTargetBehavior.cs
Normal file
92
EmbyToolbox/Behaviors/VideoInfoDropTargetBehavior.cs
Normal file
@ -0,0 +1,92 @@
|
||||
using System.Windows;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox.Behaviors;
|
||||
|
||||
public static class VideoInfoDropTargetBehavior
|
||||
{
|
||||
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
|
||||
"IsEnabled",
|
||||
typeof(bool),
|
||||
typeof(VideoInfoDropTargetBehavior),
|
||||
new PropertyMetadata(false, OnIsEnabledChanged));
|
||||
|
||||
public static void SetIsEnabled(DependencyObject d, bool value) => d.SetValue(IsEnabledProperty, value);
|
||||
public static bool GetIsEnabled(DependencyObject d) => (bool)d.GetValue(IsEnabledProperty);
|
||||
|
||||
private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not FrameworkElement fe)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fe.AllowDrop = false;
|
||||
fe.PreviewDragOver -= OnPreviewDragOver;
|
||||
fe.PreviewDragLeave -= OnPreviewDragLeave;
|
||||
fe.PreviewDrop -= OnPreviewDrop;
|
||||
|
||||
if (e.NewValue is true)
|
||||
{
|
||||
fe.AllowDrop = true;
|
||||
fe.PreviewDragOver += OnPreviewDragOver;
|
||||
fe.PreviewDragLeave += OnPreviewDragLeave;
|
||||
fe.PreviewDrop += OnPreviewDrop;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not VideoInfoViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (vm.IsBusy)
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsVideoInfoDropHighlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||
{
|
||||
e.Effects = DragDropEffects.Copy;
|
||||
e.Handled = true;
|
||||
vm.IsVideoInfoDropHighlight = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
vm.IsVideoInfoDropHighlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPreviewDragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not VideoInfoViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsVideoInfoDropHighlight = false;
|
||||
}
|
||||
|
||||
private static void OnPreviewDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not VideoInfoViewModel vm)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vm.IsVideoInfoDropHighlight = false;
|
||||
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vm.ApplyDroppedPathsAndAnalyze(paths);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
17
EmbyToolbox/Converters/BooleanNegationConverter.cs
Normal file
17
EmbyToolbox/Converters/BooleanNegationConverter.cs
Normal file
@ -0,0 +1,17 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace EmbyToolbox.Converters;
|
||||
|
||||
public sealed class BooleanNegationConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value is bool b ? !b : true;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value is bool b ? !b : false;
|
||||
}
|
||||
}
|
||||
22
EmbyToolbox/Converters/BooleanToVisibilityConverter.cs
Normal file
22
EmbyToolbox/Converters/BooleanToVisibilityConverter.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace EmbyToolbox.Converters;
|
||||
|
||||
public sealed class BooleanToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var flag = value is true;
|
||||
if (parameter is string p && string.Equals(p, "Invert", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
flag = !flag;
|
||||
}
|
||||
|
||||
return flag ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is Visibility.Visible;
|
||||
}
|
||||
28
EmbyToolbox/Converters/TrackActionKindToRussianConverter.cs
Normal file
28
EmbyToolbox/Converters/TrackActionKindToRussianConverter.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Converters;
|
||||
|
||||
public sealed class TrackActionKindToRussianConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not TrackActionKind action)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return action switch
|
||||
{
|
||||
TrackActionKind.Keep => "Оставить",
|
||||
TrackActionKind.Convert => "Конвертировать",
|
||||
TrackActionKind.Remove => "Удалить",
|
||||
TrackActionKind.Add => "Добавить",
|
||||
_ => action.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
32
EmbyToolbox/EmbyToolbox.csproj
Normal file
32
EmbyToolbox/EmbyToolbox.csproj
Normal file
@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0-windows10.0.17763.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||
<ApplicationIcon>Resources\AppIcon.ico</ApplicationIcon>
|
||||
<PackageIcon>icons8-emby-96.png</PackageIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Tools\ffmpeg.exe">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Tools\ffprobe.exe">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="Resources\AppIcon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Resources\Icons\icons8-emby-96.png">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
49
EmbyToolbox/Interop/AppUserModelIdRegistration.cs
Normal file
49
EmbyToolbox/Interop/AppUserModelIdRegistration.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace EmbyToolbox.Interop;
|
||||
|
||||
/// <summary>Вызов SetCurrentProcessExplicitAppUserModelID до показа окон/unpackaged toasts Windows.</summary>
|
||||
internal static class AppUserModelIdRegistration
|
||||
{
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = false)]
|
||||
private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
|
||||
|
||||
public static string? LastRegisteredId { get; private set; }
|
||||
public static int LastHr { get; private set; }
|
||||
public static string? LastDiagnostics { get; private set; }
|
||||
|
||||
/// <returns>true, если получен код 0 (S_OK).</returns>
|
||||
public static bool TryRegister(string appUserModelId)
|
||||
{
|
||||
LastDiagnostics = null;
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(6, 1))
|
||||
{
|
||||
LastDiagnostics = "требуется Windows.";
|
||||
LastHr = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var hr = SetCurrentProcessExplicitAppUserModelID(appUserModelId);
|
||||
LastHr = hr;
|
||||
LastRegisteredId = hr == 0 ? appUserModelId : null;
|
||||
|
||||
if (hr != 0)
|
||||
{
|
||||
LastDiagnostics =
|
||||
$"SetCurrentProcessExplicitAppUserModelID вернул 0x{hr:X8} для «{appUserModelId}».";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastHr = -2;
|
||||
LastDiagnostics = $"{ex.GetType().Name}: {ex.Message}";
|
||||
LastRegisteredId = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
1062
EmbyToolbox/MainWindow.xaml
Normal file
1062
EmbyToolbox/MainWindow.xaml
Normal file
File diff suppressed because it is too large
Load Diff
31
EmbyToolbox/MainWindow.xaml.cs
Normal file
31
EmbyToolbox/MainWindow.xaml.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new MainWindowViewModel();
|
||||
}
|
||||
|
||||
private void OnJsonTreeItemPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var dependencyObject = e.OriginalSource as DependencyObject;
|
||||
while (dependencyObject is not null && dependencyObject is not TreeViewItem)
|
||||
{
|
||||
dependencyObject = VisualTreeHelper.GetParent(dependencyObject);
|
||||
}
|
||||
|
||||
if (dependencyObject is TreeViewItem item)
|
||||
{
|
||||
item.IsSelected = true;
|
||||
item.Focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
6
EmbyToolbox/Models/AddFilesOptions.cs
Normal file
6
EmbyToolbox/Models/AddFilesOptions.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public sealed class AddFilesOptions
|
||||
{
|
||||
public bool RemoveForeignAudioAndSubtitles { get; init; }
|
||||
}
|
||||
19
EmbyToolbox/Models/ConversionPlan.cs
Normal file
19
EmbyToolbox/Models/ConversionPlan.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>План шагов конвертации и краткое отображение.</summary>
|
||||
public sealed class ConversionPlan
|
||||
{
|
||||
public IReadOnlyList<string> StepDescriptions { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<ConversionTrackPlan> TrackParts { get; init; } = Array.Empty<ConversionTrackPlan>();
|
||||
public ConversionPlanActionStats ActionStats { get; init; } = default;
|
||||
public string ShortSummary { get; init; } = string.Empty;
|
||||
public bool SuggestsSkip { get; init; }
|
||||
public bool HasRealActions { get; init; }
|
||||
public string TargetVideoBitrateMode { get; init; } = string.Empty;
|
||||
public int? TargetVideoBitrateKbps { get; init; }
|
||||
/// <summary>MPEG-TS → MKV: первичная попытка copy с genpts; при ошибке timestamp возможен fallback на перекодирование видео.</summary>
|
||||
public bool RequiresTimestampFix { get; init; }
|
||||
}
|
||||
20
EmbyToolbox/Models/ConversionPlanAction.cs
Normal file
20
EmbyToolbox/Models/ConversionPlanAction.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Тип элемента плана (для расширения, отображение в UI — строки в <see cref="ConversionPlan"/>).</summary>
|
||||
public enum ConversionPlanAction
|
||||
{
|
||||
None,
|
||||
Skip,
|
||||
RemuxToMkv,
|
||||
RemuxToMp4,
|
||||
ConvertVideo,
|
||||
ConvertPixelFormat,
|
||||
Resize,
|
||||
LimitFps,
|
||||
ConvertAudio,
|
||||
RemoveNonRusAudio,
|
||||
RemoveNonRusSubtitles,
|
||||
AddExternalAudio,
|
||||
AddExternalSubtitles,
|
||||
RemoveDataStreams
|
||||
}
|
||||
77
EmbyToolbox/Models/ConversionPlanActionStats.cs
Normal file
77
EmbyToolbox/Models/ConversionPlanActionStats.cs
Normal file
@ -0,0 +1,77 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Сводка количеств операций в плане (источник — <see cref="ConversionTaskOverride"/> + профиль).</summary>
|
||||
public readonly record struct ConversionPlanActionStats(
|
||||
int Add,
|
||||
int Remove,
|
||||
int ConvertAudio,
|
||||
int ConvertVideo,
|
||||
int SubtitleRemove,
|
||||
int SubtitleConvert,
|
||||
int SubtitleKeep)
|
||||
{
|
||||
public static ConversionPlanActionStats FromOverrides(ConversionTaskOverride? ovr)
|
||||
{
|
||||
if (ovr is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var add = 0;
|
||||
var remove = 0;
|
||||
var cAudio = 0;
|
||||
var cVideo = 0;
|
||||
var sRem = 0;
|
||||
var sConv = 0;
|
||||
var sKeep = 0;
|
||||
foreach (var t in ovr.TrackOverrides)
|
||||
{
|
||||
if (t.Source == SourceKind.External)
|
||||
{
|
||||
if (t.Action == TrackActionKind.Add)
|
||||
{
|
||||
add++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (t.Action == TrackActionKind.Remove)
|
||||
{
|
||||
remove++;
|
||||
}
|
||||
|
||||
if (t.Action == TrackActionKind.Convert)
|
||||
{
|
||||
if (t.StreamKind == MediaStreamKind.Audio)
|
||||
{
|
||||
cAudio++;
|
||||
}
|
||||
|
||||
if (t.StreamKind == MediaStreamKind.Video)
|
||||
{
|
||||
cVideo++;
|
||||
}
|
||||
|
||||
if (t.StreamKind == MediaStreamKind.Subtitle)
|
||||
{
|
||||
sConv++;
|
||||
}
|
||||
}
|
||||
|
||||
if (t.StreamKind == MediaStreamKind.Subtitle)
|
||||
{
|
||||
if (t.Action == TrackActionKind.Remove)
|
||||
{
|
||||
sRem++;
|
||||
}
|
||||
else if (t.Action == TrackActionKind.Keep)
|
||||
{
|
||||
sKeep++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ConversionPlanActionStats(add, remove, cAudio, cVideo, sRem, sConv, sKeep);
|
||||
}
|
||||
}
|
||||
656
EmbyToolbox/Models/ConversionQueueItem.cs
Normal file
656
EmbyToolbox/Models/ConversionQueueItem.cs
Normal file
@ -0,0 +1,656 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public sealed class ConversionQueueItem : INotifyPropertyChanged
|
||||
{
|
||||
private string _fullPath;
|
||||
private string _fileName;
|
||||
private string _directoryPath;
|
||||
private int _orderNumber;
|
||||
private int _progress;
|
||||
private string _status = ConversionQueueStatus.Pending;
|
||||
private string _profile = "Emby";
|
||||
private string _planSummary = string.Empty;
|
||||
private int _fileSizeMb;
|
||||
/// <summary>True после успешного ffprobe (аудио-поля валидны для отображения).</summary>
|
||||
private bool _ffprobeAnalyzed;
|
||||
private int _ffprobeAudioCount;
|
||||
private int? _ffprobeAudioSizeMb;
|
||||
private bool _ffprobeAudioSizePartial;
|
||||
private MediaAnalysisResult? _mediaAnalysis;
|
||||
private IReadOnlyList<SidecarFile> _sidecars = System.Array.Empty<SidecarFile>();
|
||||
private IReadOnlyList<ExternalAudioFile> _externalAudioFiles = System.Array.Empty<ExternalAudioFile>();
|
||||
private ConversionPlan? _lastPlan;
|
||||
private bool _isProcessed;
|
||||
private bool _processedInCurrentRun;
|
||||
private string? _lastRunId;
|
||||
private bool _isSkipPlan = true;
|
||||
private bool _isManuallyEdited;
|
||||
private string? _errorMessage;
|
||||
private string? _errorDetails;
|
||||
|
||||
public string FullPath
|
||||
{
|
||||
get => _fullPath;
|
||||
private set
|
||||
{
|
||||
if (string.Equals(_fullPath, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_fullPath = value;
|
||||
_directoryPath = Path.GetDirectoryName(value) ?? string.Empty;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(DirectoryPath));
|
||||
}
|
||||
}
|
||||
|
||||
public string FileName
|
||||
{
|
||||
get => _fileName;
|
||||
private set
|
||||
{
|
||||
if (string.Equals(_fileName, value, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_fileName = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string DirectoryPath
|
||||
{
|
||||
get => _directoryPath;
|
||||
private set
|
||||
{
|
||||
if (string.Equals(_directoryPath, value, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_directoryPath = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Параметры из ffprobe + списки потоков.</summary>
|
||||
public MediaAnalysisResult? MediaAnalysis
|
||||
{
|
||||
get => _mediaAnalysis;
|
||||
private set
|
||||
{
|
||||
if (ReferenceEquals(_mediaAnalysis, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_mediaAnalysis = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(TrackSummaryDisplay));
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SidecarFile> Sidecars
|
||||
{
|
||||
get => _sidecars;
|
||||
private set
|
||||
{
|
||||
if (Equals(_sidecars, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sidecars = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Общий корень батча (добавление каталогом или вычисленный LCA файлов при перетаскивании/мультовыборе). Для области snapshot между эпизодами одного добавления.</summary>
|
||||
public string? SnapshotScopeBatchRoot { get; set; }
|
||||
|
||||
/// <summary>Разбор внешних аудиофайлов (мультипотоковые контейнеры и т.д.) для пере-seed дорожек.</summary>
|
||||
public IReadOnlyList<ExternalAudioFile> ExternalAudioFiles
|
||||
{
|
||||
get => _externalAudioFiles;
|
||||
private set
|
||||
{
|
||||
if (Equals(_externalAudioFiles, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_externalAudioFiles = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public ConversionTaskOverride TaskOverride { get; } = new();
|
||||
|
||||
public ConversionPlan? LastPlan
|
||||
{
|
||||
get => _lastPlan;
|
||||
private set
|
||||
{
|
||||
if (Equals(_lastPlan, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastPlan = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public ConversionQueueItem(string fullPath)
|
||||
{
|
||||
var normalized = Path.GetFullPath(fullPath);
|
||||
_fullPath = normalized;
|
||||
_fileName = Path.GetFileName(normalized);
|
||||
_directoryPath = Path.GetDirectoryName(normalized) ?? string.Empty;
|
||||
SetInitialFileSizeBytes();
|
||||
}
|
||||
|
||||
/// <summary>Размер файла, МБ (целое, округление).</summary>
|
||||
public int FileSizeMb
|
||||
{
|
||||
get => _fileSizeMb;
|
||||
private set
|
||||
{
|
||||
if (_fileSizeMb == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_fileSizeMb = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetInitialFileSizeBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FullPath))
|
||||
{
|
||||
var len = new FileInfo(FullPath).Length;
|
||||
FileSizeMb = (int)Math.Round(len / 1024.0 / 1024.0, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
FileSizeMb = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFileSizeFromDisk() => SetInitialFileSizeBytes();
|
||||
|
||||
/// <summary>Восстановление медиаданных из снимка (загрузка очереди) без затрагивания <see cref="IsManuallyEdited"/>.</summary>
|
||||
public void RestorePersistedMediaSnapshot(
|
||||
MediaAnalysisResult media,
|
||||
IReadOnlyList<SidecarFile> sidecars,
|
||||
IReadOnlyList<ExternalAudioFile> externalAudioFiles,
|
||||
bool hasFfprobeAudioSummary,
|
||||
int ffprobeAudioCount,
|
||||
int? ffprobeAudioSizeMb,
|
||||
bool ffprobeAudioSizePartial)
|
||||
{
|
||||
MediaAnalysis = media;
|
||||
Sidecars = sidecars;
|
||||
ExternalAudioFiles = externalAudioFiles;
|
||||
if (hasFfprobeAudioSummary)
|
||||
{
|
||||
SetFfprobeAudioData(ffprobeAudioCount, ffprobeAudioSizeMb, ffprobeAudioSizePartial);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ffprobeAnalyzed = false;
|
||||
_ffprobeAudioCount = 0;
|
||||
_ffprobeAudioSizeMb = null;
|
||||
_ffprobeAudioSizePartial = false;
|
||||
OnPropertyChanged(nameof(AudioTracksDisplay));
|
||||
OnPropertyChanged(nameof(AudioTracksSortValue));
|
||||
OnPropertyChanged(nameof(AudioSizeMbDisplay));
|
||||
OnPropertyChanged(nameof(AudioSizeSortValue));
|
||||
OnPropertyChanged(nameof(AudioSizeMbToolTip));
|
||||
OnPropertyChanged(nameof(HasFfprobeAudioSummary));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Есть ли сохранённые краткие данные ffprobe по аудио (для .conv_setup).</summary>
|
||||
public bool HasFfprobeAudioSummary => _ffprobeAnalyzed;
|
||||
|
||||
/// <summary>Число аудиопотоков из последнего анализа (0, если не анализировалось).</summary>
|
||||
public int FfprobeEmbeddedAudioStreamCount => _ffprobeAnalyzed ? _ffprobeAudioCount : 0;
|
||||
|
||||
public int? FfprobeAudioSizeEstimateMb => _ffprobeAudioSizeMb;
|
||||
|
||||
public bool FfprobeAudioSizeEstimatePartial => _ffprobeAudioSizePartial;
|
||||
|
||||
public string AudioTracksDisplay => !_ffprobeAnalyzed ? "-" : _ffprobeAudioCount.ToString();
|
||||
public int AudioTracksSortValue => _ffprobeAnalyzed ? _ffprobeAudioCount : -1;
|
||||
|
||||
public string AudioSizeMbDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_ffprobeAnalyzed)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (_ffprobeAudioSizeMb is not int audioMb)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
return _ffprobeAudioSizePartial ? $"{audioMb}*" : audioMb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public string TrackSummaryDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
if (MediaAnalysis is null)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
var videoTotal = MediaAnalysis.VideoStreams.Count;
|
||||
var audioTotal = MediaAnalysis.AudioStreams.Count;
|
||||
var subtitleTotal = MediaAnalysis.SubtitleStreams.Count;
|
||||
var attachmentTotal = MediaAnalysis.AllStreams.Count(s => s.Kind == MediaStreamKind.Attachment);
|
||||
var overrides = TaskOverride.TrackOverrides;
|
||||
if (overrides.Count > 0)
|
||||
{
|
||||
foreach (var track in overrides)
|
||||
{
|
||||
if (track.StreamKind == MediaStreamKind.Video)
|
||||
{
|
||||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||||
{
|
||||
videoTotal--;
|
||||
}
|
||||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||||
{
|
||||
videoTotal++;
|
||||
}
|
||||
}
|
||||
else if (track.StreamKind == MediaStreamKind.Audio)
|
||||
{
|
||||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||||
{
|
||||
audioTotal--;
|
||||
}
|
||||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||||
{
|
||||
audioTotal++;
|
||||
}
|
||||
}
|
||||
else if (track.StreamKind == MediaStreamKind.Subtitle)
|
||||
{
|
||||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||||
{
|
||||
subtitleTotal--;
|
||||
}
|
||||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||||
{
|
||||
subtitleTotal++;
|
||||
}
|
||||
}
|
||||
else if (track.StreamKind == MediaStreamKind.Attachment)
|
||||
{
|
||||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||||
{
|
||||
attachmentTotal--;
|
||||
}
|
||||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||||
{
|
||||
attachmentTotal++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (videoTotal < 0)
|
||||
{
|
||||
videoTotal = 0;
|
||||
}
|
||||
|
||||
if (audioTotal < 0)
|
||||
{
|
||||
audioTotal = 0;
|
||||
}
|
||||
|
||||
if (subtitleTotal < 0)
|
||||
{
|
||||
subtitleTotal = 0;
|
||||
}
|
||||
|
||||
if (attachmentTotal < 0)
|
||||
{
|
||||
attachmentTotal = 0;
|
||||
}
|
||||
|
||||
return $"🎬 {videoTotal} 🔊 {audioTotal} 💬 {subtitleTotal} 📎 {attachmentTotal}";
|
||||
}
|
||||
}
|
||||
|
||||
public int AudioSizeSortValue => _ffprobeAnalyzed && _ffprobeAudioSizeMb is { } mb ? mb : -1;
|
||||
|
||||
/// <summary>Подсказка для «размер аудио» с пометкой * о частичном расчёте.</summary>
|
||||
public string? AudioSizeMbToolTip
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_ffprobeAnalyzed && _ffprobeAudioSizePartial)
|
||||
{
|
||||
return "* расчет частичный, у части дорожек неизвестен bitrate";
|
||||
}
|
||||
|
||||
if (_ffprobeAnalyzed && _ffprobeAudioSizeMb is not null && _ffprobeAudioSizeMb >= 0)
|
||||
{
|
||||
return "Суммарная оценка: длительность × битрейт по дорожкам, МБ (целое).";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFfprobeAudioData(int trackCount, int? audioSizeMb, bool isPartial)
|
||||
{
|
||||
_ffprobeAnalyzed = true;
|
||||
_ffprobeAudioCount = trackCount;
|
||||
_ffprobeAudioSizeMb = audioSizeMb;
|
||||
_ffprobeAudioSizePartial = isPartial;
|
||||
OnPropertyChanged(nameof(AudioTracksDisplay));
|
||||
OnPropertyChanged(nameof(AudioTracksSortValue));
|
||||
OnPropertyChanged(nameof(AudioSizeMbDisplay));
|
||||
OnPropertyChanged(nameof(AudioSizeSortValue));
|
||||
OnPropertyChanged(nameof(AudioSizeMbToolTip));
|
||||
OnPropertyChanged(nameof(HasFfprobeAudioSummary));
|
||||
}
|
||||
|
||||
public void SetSuccessfulMediaAnalysis(
|
||||
MediaAnalysisResult media,
|
||||
IReadOnlyList<SidecarFile> sidecars,
|
||||
IReadOnlyList<ExternalAudioFile> externalAudioFiles,
|
||||
ConversionPlan plan,
|
||||
int audioCount,
|
||||
int? audioSizeMb,
|
||||
bool audioSizePartial)
|
||||
{
|
||||
MediaAnalysis = media;
|
||||
Sidecars = sidecars;
|
||||
ExternalAudioFiles = externalAudioFiles;
|
||||
LastPlan = plan;
|
||||
PlanSummary = string.IsNullOrWhiteSpace(plan.ShortSummary) ? "Skip — обработка не требуется" : plan.ShortSummary;
|
||||
IsManuallyEdited = false;
|
||||
RecomputeSkipFlag();
|
||||
SetFfprobeAudioData(audioCount, audioSizeMb, audioSizePartial);
|
||||
}
|
||||
|
||||
public void SetPlan(ConversionPlan plan)
|
||||
{
|
||||
LastPlan = plan;
|
||||
PlanSummary = string.IsNullOrWhiteSpace(plan.ShortSummary) ? "Skip — обработка не требуется" : plan.ShortSummary;
|
||||
RecomputeSkipFlag();
|
||||
OnPropertyChanged(nameof(TrackSummaryDisplay));
|
||||
}
|
||||
|
||||
public int OrderNumber
|
||||
{
|
||||
get => _orderNumber;
|
||||
set
|
||||
{
|
||||
if (_orderNumber == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_orderNumber = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(DisplayIndexText));
|
||||
}
|
||||
}
|
||||
|
||||
public string Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (_status == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_status = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(StatusSortOrder));
|
||||
OnPropertyChanged(nameof(DisplayProgressPercent));
|
||||
}
|
||||
}
|
||||
|
||||
public int StatusSortOrder => _status switch
|
||||
{
|
||||
ConversionQueueStatus.Pending => 0,
|
||||
ConversionQueueStatus.Running => 1,
|
||||
ConversionQueueStatus.Copying => 2,
|
||||
ConversionQueueStatus.Replacing => 3,
|
||||
ConversionQueueStatus.Done => 4,
|
||||
ConversionQueueStatus.Error => 5,
|
||||
ConversionQueueStatus.Cancelled => 6,
|
||||
_ => 99
|
||||
};
|
||||
|
||||
/// <summary>Сырое значение 0–100 для пайплайна (ffmpeg/копирование); в UI использовать <see cref="DisplayProgressPercent"/>.</summary>
|
||||
public int Progress
|
||||
{
|
||||
get => _progress;
|
||||
set
|
||||
{
|
||||
if (_progress == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_progress = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(DisplayProgressPercent));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Прогресс для интерфейса: 100% только при статусе «Готово»; иначе не выше 99.</summary>
|
||||
public int DisplayProgressPercent =>
|
||||
string.Equals(_status, ConversionQueueStatus.Done, StringComparison.Ordinal)
|
||||
? 100
|
||||
: Math.Min(99, Math.Max(0, _progress));
|
||||
|
||||
public string Profile
|
||||
{
|
||||
get => _profile;
|
||||
set
|
||||
{
|
||||
if (_profile == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_profile = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string PlanSummary
|
||||
{
|
||||
get => _planSummary;
|
||||
set
|
||||
{
|
||||
if (_planSummary == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_planSummary = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSkipPlan
|
||||
{
|
||||
get => _isSkipPlan;
|
||||
set
|
||||
{
|
||||
if (_isSkipPlan == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isSkipPlan = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsManuallyEdited
|
||||
{
|
||||
get => _isManuallyEdited;
|
||||
set
|
||||
{
|
||||
if (_isManuallyEdited == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isManuallyEdited = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(DisplayIndexText));
|
||||
OnPropertyChanged(nameof(ManualEditToolTip));
|
||||
RecomputeSkipFlag();
|
||||
}
|
||||
}
|
||||
|
||||
public string DisplayIndexText => IsManuallyEdited ? $"✎ {OrderNumber}" : OrderNumber.ToString();
|
||||
|
||||
public string? ManualEditToolTip => IsManuallyEdited ? "Настройки изменены вручную" : null;
|
||||
|
||||
public bool IsProcessed
|
||||
{
|
||||
get => _isProcessed;
|
||||
set
|
||||
{
|
||||
if (_isProcessed == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isProcessed = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool ProcessedInCurrentRun
|
||||
{
|
||||
get => _processedInCurrentRun;
|
||||
set
|
||||
{
|
||||
if (_processedInCurrentRun == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_processedInCurrentRun = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? LastRunId
|
||||
{
|
||||
get => _lastRunId;
|
||||
set
|
||||
{
|
||||
if (_lastRunId == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastRunId = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
set
|
||||
{
|
||||
if (_errorMessage == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_errorMessage = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Подробный текст ошибки (stderr ffmpeg/ffprobe и т.д.); для буфера приоритетнее краткого <see cref="ErrorMessage"/>.</summary>
|
||||
public string? ErrorDetails
|
||||
{
|
||||
get => _errorDetails;
|
||||
set
|
||||
{
|
||||
if (_errorDetails == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_errorDetails = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
private void RecomputeSkipFlag()
|
||||
{
|
||||
IsSkipPlan = LastPlan?.HasRealActions is false;
|
||||
}
|
||||
|
||||
public void UpdateOutputPath(string newPath, string? newContainerFormat)
|
||||
{
|
||||
var normalized = Path.GetFullPath(newPath);
|
||||
FullPath = normalized;
|
||||
FileName = Path.GetFileName(normalized);
|
||||
DirectoryPath = Path.GetDirectoryName(normalized) ?? string.Empty;
|
||||
SetInitialFileSizeBytes();
|
||||
if (MediaAnalysis is { } m)
|
||||
{
|
||||
MediaAnalysis = new MediaAnalysisResult
|
||||
{
|
||||
ContainerFormat = newContainerFormat ?? m.ContainerFormat,
|
||||
FormatName = m.FormatName,
|
||||
FormatBitRateBps = m.FormatBitRateBps,
|
||||
DurationSeconds = m.DurationSeconds,
|
||||
VideoStreams = m.VideoStreams,
|
||||
AudioStreams = m.AudioStreams,
|
||||
SubtitleStreams = m.SubtitleStreams,
|
||||
DataStreams = m.DataStreams,
|
||||
AllStreams = m.AllStreams,
|
||||
SourceVideoBitrateBps = m.SourceVideoBitrateBps
|
||||
};
|
||||
OnPropertyChanged(nameof(TrackSummaryDisplay));
|
||||
}
|
||||
}
|
||||
}
|
||||
38
EmbyToolbox/Models/ConversionQueueItemErrorCopy.cs
Normal file
38
EmbyToolbox/Models/ConversionQueueItemErrorCopy.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Правила контекстного меню «Копировать ошибку» и состава текста буфера обмена.</summary>
|
||||
public static class ConversionQueueItemErrorCopy
|
||||
{
|
||||
public static bool IsEligibleItem(ConversionQueueItem? item)
|
||||
{
|
||||
return item is not null
|
||||
&& string.Equals(item.Status, ConversionQueueStatus.Error, StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(item.ErrorMessage);
|
||||
}
|
||||
|
||||
public static bool ShouldShowForSelection(IList? selected)
|
||||
{
|
||||
if (selected is not { Count: 1 })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsEligibleItem(selected[0] as ConversionQueueItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Если есть подробный вывод (ffmpeg stderr, ffprobe stderr и т.д.) — только он; иначе краткий <see cref="ConversionQueueItem.ErrorMessage"/>.
|
||||
/// </summary>
|
||||
public static string GetClipboardText(ConversionQueueItem item)
|
||||
{
|
||||
var detail = item.ErrorDetails?.Trim();
|
||||
if (!string.IsNullOrEmpty(detail))
|
||||
{
|
||||
return detail;
|
||||
}
|
||||
|
||||
return item.ErrorMessage?.Trim() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
15
EmbyToolbox/Models/ConversionQueueStatus.cs
Normal file
15
EmbyToolbox/Models/ConversionQueueStatus.cs
Normal file
@ -0,0 +1,15 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public static class ConversionQueueStatus
|
||||
{
|
||||
public const string Pending = "В очереди";
|
||||
public const string Analyzing = "Анализ";
|
||||
public const string Ready = "Готов";
|
||||
public const string Running = "В работе";
|
||||
public const string Copying = "Копирование";
|
||||
public const string Replacing = "Замена";
|
||||
public const string Done = "Готово";
|
||||
public const string Skipped = "Пропуск";
|
||||
public const string Error = "Ошибка";
|
||||
public const string Cancelled = "Отмена";
|
||||
}
|
||||
90
EmbyToolbox/Models/ConversionTaskOverride.cs
Normal file
90
EmbyToolbox/Models/ConversionTaskOverride.cs
Normal file
@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Переопределения пользователя: видео-параметры и дорожки.</summary>
|
||||
public sealed class ConversionTaskOverride
|
||||
{
|
||||
public string TargetContainer { get; set; } = string.Empty;
|
||||
public string TargetVideo { get; set; } = string.Empty;
|
||||
public string TargetPixelFormat { get; set; } = string.Empty;
|
||||
public string TargetResolution { get; set; } = string.Empty;
|
||||
public string TargetFps { get; set; } = string.Empty;
|
||||
public string TargetAudioBitrate { get; set; } = "256 kbps";
|
||||
public string TargetVideoBitrateMode { get; set; } = "Auto";
|
||||
public double? TargetVideoBitrateMbps { get; set; }
|
||||
public List<TrackOverrideEntry> TrackOverrides { get; } = new();
|
||||
|
||||
public void CopyFrom(ConversionTaskOverride o)
|
||||
{
|
||||
TargetContainer = o.TargetContainer;
|
||||
TargetVideo = o.TargetVideo;
|
||||
TargetPixelFormat = o.TargetPixelFormat;
|
||||
TargetResolution = o.TargetResolution;
|
||||
TargetFps = o.TargetFps;
|
||||
TargetAudioBitrate = o.TargetAudioBitrate;
|
||||
TargetVideoBitrateMode = o.TargetVideoBitrateMode;
|
||||
TargetVideoBitrateMbps = o.TargetVideoBitrateMbps;
|
||||
TrackOverrides.Clear();
|
||||
foreach (var t in o.TrackOverrides)
|
||||
{
|
||||
TrackOverrides.Add(t.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
public ConversionTaskOverride Clone()
|
||||
{
|
||||
var c = new ConversionTaskOverride();
|
||||
c.CopyFrom(this);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TrackOverrideEntry
|
||||
{
|
||||
/// <summary>Stream index (ffprobe) для встроенных; отрицательный — внешняя дорожка, см. <see cref="ExternalPath"/>.</summary>
|
||||
public int StreamIndex { get; init; }
|
||||
public string? ExternalPath { get; init; }
|
||||
public SourceKind Source { get; init; }
|
||||
public MediaStreamKind StreamKind { get; init; }
|
||||
public TrackActionKind Action { get; set; } = TrackActionKind.Keep;
|
||||
public bool? Default { get; set; }
|
||||
public string? Language { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? AudioBitrateKbps { get; set; }
|
||||
|
||||
/// <summary>Для внешнего аудио: порядковый номер потока внутри файла (0…) для ffmpeg <c>-map</c> <i>input:a:N</i>.</summary>
|
||||
public int ExternalAudioStreamOrdinal { get; set; }
|
||||
|
||||
/// <summary>Кодек потока (ffprobe) для отображения / snapshot при внешнем аудио.</summary>
|
||||
public string? ExternalStreamCodec { get; set; }
|
||||
|
||||
/// <summary>Сводка для столбца Details у внешних дорожек.</summary>
|
||||
public string? ExternalStreamDetails { get; set; }
|
||||
|
||||
/// <summary>Число аудиопотоков в том же внешнем файле (для заголовка по умолчанию / snapshot).</summary>
|
||||
public int SameFileExternalAudioStreamCount { get; set; } = 1;
|
||||
|
||||
/// <summary>При внешнем аудио: title из ffprobe-тегов, если был; иначе <see langword="null"/> (заголовок сгенерирован из имени файла).</summary>
|
||||
public string? ExternalFfprobeTitle { get; set; }
|
||||
|
||||
public TrackOverrideEntry Clone() =>
|
||||
new()
|
||||
{
|
||||
StreamIndex = StreamIndex,
|
||||
ExternalPath = ExternalPath,
|
||||
Source = Source,
|
||||
StreamKind = StreamKind,
|
||||
Action = Action,
|
||||
Default = Default,
|
||||
Language = Language,
|
||||
Title = Title,
|
||||
AudioBitrateKbps = AudioBitrateKbps,
|
||||
ExternalAudioStreamOrdinal = ExternalAudioStreamOrdinal,
|
||||
ExternalStreamCodec = ExternalStreamCodec,
|
||||
ExternalStreamDetails = ExternalStreamDetails,
|
||||
SameFileExternalAudioStreamCount = SameFileExternalAudioStreamCount,
|
||||
ExternalFfprobeTitle = ExternalFfprobeTitle
|
||||
};
|
||||
}
|
||||
11
EmbyToolbox/Models/ConversionTrackPlan.cs
Normal file
11
EmbyToolbox/Models/ConversionTrackPlan.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Краткое описание плана по одной логической дорожке/шагу.</summary>
|
||||
public sealed class ConversionTrackPlan
|
||||
{
|
||||
public int? StreamIndex { get; init; }
|
||||
public SourceKind? Source { get; init; }
|
||||
public MediaStreamKind StreamKind { get; init; }
|
||||
public ConversionPlanAction Action { get; init; }
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
44
EmbyToolbox/Models/ExternalAudioDiscovery.cs
Normal file
44
EmbyToolbox/Models/ExternalAudioDiscovery.cs
Normal file
@ -0,0 +1,44 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Сторонний аудиофайл (контейнер или raw), возможно с несколькими аудиопотоками.</summary>
|
||||
public sealed class ExternalAudioFile
|
||||
{
|
||||
public ExternalAudioFile(string fullPath, IReadOnlyList<ExternalAudioStream> streams)
|
||||
{
|
||||
FullPath = fullPath;
|
||||
Streams = streams;
|
||||
}
|
||||
|
||||
public string FullPath { get; }
|
||||
public IReadOnlyList<ExternalAudioStream> Streams { get; }
|
||||
}
|
||||
|
||||
/// <summary>Один аудиопоток внутри <see cref="ExternalAudioFile"/> (индекс для ffmpeg <c>-map</c> <i>input:a:N</i>).</summary>
|
||||
public sealed class ExternalAudioStream
|
||||
{
|
||||
public required string FileFullPath { get; init; }
|
||||
|
||||
/// <summary>Порядковый номер аудиопотока внутри файла (0 = первый audio), для <c>-map M:a:N</c>.</summary>
|
||||
public int StreamOrdinal { get; init; }
|
||||
|
||||
public required string CodecName { get; init; }
|
||||
public string? TitleFromProbe { get; init; }
|
||||
public int? Channels { get; init; }
|
||||
public int? SampleRateHz { get; init; }
|
||||
public long? BitRateBps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Результат поиска sidecar: объединённые файлы для плана/ffmpeg и разобранное внешнее аудио.</summary>
|
||||
public sealed class SidecarDiscoveryResult
|
||||
{
|
||||
public SidecarDiscoveryResult(IReadOnlyList<SidecarFile> sidecars, IReadOnlyList<ExternalAudioFile> externalAudioFiles)
|
||||
{
|
||||
Sidecars = sidecars;
|
||||
ExternalAudioFiles = externalAudioFiles;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SidecarFile> Sidecars { get; }
|
||||
|
||||
/// <summary>Разбор аудиофайлов (пути совпадают с audio-<see cref="SidecarFile"/>).</summary>
|
||||
public IReadOnlyList<ExternalAudioFile> ExternalAudioFiles { get; }
|
||||
}
|
||||
47
EmbyToolbox/Models/MediaAnalysisResult.cs
Normal file
47
EmbyToolbox/Models/MediaAnalysisResult.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Результат детального ffprobe.</summary>
|
||||
public sealed class MediaAnalysisResult
|
||||
{
|
||||
public string? ContainerFormat { get; init; }
|
||||
public string? FormatName { get; init; }
|
||||
public long? FormatBitRateBps { get; init; }
|
||||
public double? DurationSeconds { get; init; }
|
||||
public IReadOnlyList<MediaStreamInfo> VideoStreams { get; init; } = Array.Empty<MediaStreamInfo>();
|
||||
public IReadOnlyList<MediaStreamInfo> AudioStreams { get; init; } = Array.Empty<MediaStreamInfo>();
|
||||
public IReadOnlyList<MediaStreamInfo> SubtitleStreams { get; init; } = Array.Empty<MediaStreamInfo>();
|
||||
public IReadOnlyList<MediaStreamInfo> DataStreams { get; init; } = Array.Empty<MediaStreamInfo>();
|
||||
public IReadOnlyList<MediaStreamInfo> AllStreams { get; init; } = Array.Empty<MediaStreamInfo>();
|
||||
public long? SourceVideoBitrateBps { get; init; }
|
||||
|
||||
/// <summary>Основной видеопоток: самый крупный по площади кадра (не первый подряд в JSON — иначе cover/mjpeg может оказаться «основным»).</summary>
|
||||
public MediaStreamInfo? PrimaryVideo =>
|
||||
VideoStreams
|
||||
.OrderByDescending(static v => ((long)(v.Width ?? 0)) * (v.Height ?? 0))
|
||||
.ThenByDescending(static v => v.IsDefault ? 1 : 0)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>format.duration, иначе максимум duration среди streams (ffprobe).</summary>
|
||||
public double? GetEffectiveDurationSeconds()
|
||||
{
|
||||
if (DurationSeconds is { } f && f > 0)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
|
||||
double? best = null;
|
||||
foreach (var s in AllStreams)
|
||||
{
|
||||
if (s.DurationSeconds is { } d && d > 0)
|
||||
{
|
||||
best = best is { } b ? Math.Max(b, d) : d;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
41
EmbyToolbox/Models/MediaStreamInfo.cs
Normal file
41
EmbyToolbox/Models/MediaStreamInfo.cs
Normal file
@ -0,0 +1,41 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Одна встроенная дорожка из ffprobe (streams[]).</summary>
|
||||
public sealed class MediaStreamInfo
|
||||
{
|
||||
public int Index { get; init; }
|
||||
public MediaStreamKind Kind { get; init; }
|
||||
public string CodecName { get; init; } = string.Empty;
|
||||
public string? Language { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public bool IsDefault { get; init; }
|
||||
public long? BitRateBps { get; init; }
|
||||
public string? Profile { get; init; }
|
||||
public string? Encoder { get; init; }
|
||||
public int? Channels { get; init; }
|
||||
public int? SampleRateHz { get; init; }
|
||||
public int? Width { get; init; }
|
||||
public int? Height { get; init; }
|
||||
public double? AverageFrameRate { get; init; }
|
||||
public double? FrameRate { get; init; }
|
||||
public string? PixelFormat { get; init; }
|
||||
/// <summary>Цветметаданные из ffprobe (video): color_space, color_primaries, color_transfer.</summary>
|
||||
public string? ColorSpace { get; init; }
|
||||
public string? ColorPrimaries { get; init; }
|
||||
public string? ColorTransfer { get; init; }
|
||||
public string? SubtitleFormat { get; init; }
|
||||
public string? FileNameTag { get; init; }
|
||||
public bool IsForcedByDisposition { get; init; }
|
||||
public bool IsForced { get; init; }
|
||||
public string? ForcedDetectionReason { get; init; }
|
||||
public int? SubtitleEventCount { get; init; }
|
||||
public double? SubtitleCoverage { get; init; }
|
||||
/// <summary>Длительность дорожки (сек) из ffprobe, если задана.</summary>
|
||||
public double? DurationSeconds { get; init; }
|
||||
|
||||
/// <summary>Для потоков-вложений matroska: тег ffprobe tags.filename.</summary>
|
||||
public string? AttachmentDeclaredFileName { get; init; }
|
||||
|
||||
/// <summary>Для потоков-вложений: tags.mimetype.</summary>
|
||||
public string? AttachmentDeclaredMimeType { get; init; }
|
||||
}
|
||||
10
EmbyToolbox/Models/MergeCompletionKind.cs
Normal file
10
EmbyToolbox/Models/MergeCompletionKind.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Итог последней попытки объединения (для статуса и единого прогресса).</summary>
|
||||
public enum MergeCompletionKind
|
||||
{
|
||||
None,
|
||||
Success,
|
||||
Cancelled,
|
||||
Error
|
||||
}
|
||||
86
EmbyToolbox/Models/MergeFileItem.cs
Normal file
86
EmbyToolbox/Models/MergeFileItem.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public sealed class MergeFileItem : INotifyPropertyChanged
|
||||
{
|
||||
private int _number;
|
||||
private string _partName = string.Empty;
|
||||
private string _status = "Готов";
|
||||
private bool _partNameUserOverride;
|
||||
|
||||
public string FullPath { get; init; } = string.Empty;
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
public int SizeMb { get; init; }
|
||||
|
||||
public int Number
|
||||
{
|
||||
get => _number;
|
||||
set
|
||||
{
|
||||
if (_number == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_number = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string PartName
|
||||
{
|
||||
get => _partName;
|
||||
set
|
||||
{
|
||||
if (_partName == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_partName = value;
|
||||
_partNameUserOverride = true;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Авто-подпись части при перестановке строк; не затирает имя, если пользователь правил вручную.</summary>
|
||||
public void SyncAutoPartName(string value)
|
||||
{
|
||||
if (_partNameUserOverride)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_partName == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_partName = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
|
||||
public string Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (_status == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_status = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
22
EmbyToolbox/Models/SidecarFile.cs
Normal file
22
EmbyToolbox/Models/SidecarFile.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Внешний sidecar-файл (аудио или субтитры) рядом с видео.</summary>
|
||||
public sealed class SidecarFile
|
||||
{
|
||||
public string FullPath { get; }
|
||||
public bool IsAudio { get; }
|
||||
public bool IsSubtitle { get; }
|
||||
public bool IsFont { get; }
|
||||
public string FileName { get; }
|
||||
|
||||
public SidecarFile(string fullPath, bool isAudio, bool isSubtitle, bool isFont = false)
|
||||
{
|
||||
FullPath = fullPath;
|
||||
FileName = System.IO.Path.GetFileName(fullPath);
|
||||
IsAudio = isAudio;
|
||||
IsSubtitle = isSubtitle;
|
||||
IsFont = isFont;
|
||||
}
|
||||
}
|
||||
7
EmbyToolbox/Models/SourceKind.cs
Normal file
7
EmbyToolbox/Models/SourceKind.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public enum SourceKind
|
||||
{
|
||||
Embedded,
|
||||
External
|
||||
}
|
||||
10
EmbyToolbox/Models/StreamKind.cs
Normal file
10
EmbyToolbox/Models/StreamKind.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public enum MediaStreamKind
|
||||
{
|
||||
Video,
|
||||
Audio,
|
||||
Subtitle,
|
||||
Attachment,
|
||||
Data
|
||||
}
|
||||
10
EmbyToolbox/Models/TrackActionKind.cs
Normal file
10
EmbyToolbox/Models/TrackActionKind.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Действие над дорожкой в плане и в окне настроек.</summary>
|
||||
public enum TrackActionKind
|
||||
{
|
||||
Keep,
|
||||
Convert,
|
||||
Remove,
|
||||
Add
|
||||
}
|
||||
262
EmbyToolbox/Models/TrackExtractionQueueItem.cs
Normal file
262
EmbyToolbox/Models/TrackExtractionQueueItem.cs
Normal file
@ -0,0 +1,262 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public sealed class TrackExtractionQueueItem : INotifyPropertyChanged
|
||||
{
|
||||
private string _fullPath = string.Empty;
|
||||
private string _fileName = string.Empty;
|
||||
private int _rowNumber = 1;
|
||||
private double _sizeMb;
|
||||
private string _audioSummary = "\u2014";
|
||||
private string _subtitleSummary = "\u2014";
|
||||
private string _attachmentSummary = "\u2014";
|
||||
private string _status = TrackExtractionStatuses.Queued;
|
||||
private double _progressPercent;
|
||||
private string _message = string.Empty;
|
||||
private MediaAnalysisResult? _mediaAnalysis;
|
||||
private int _totalTracksToExtract;
|
||||
|
||||
public TrackExtractionQueueItem(string fullPath)
|
||||
{
|
||||
RefreshPath(fullPath);
|
||||
}
|
||||
|
||||
public string FullPath
|
||||
{
|
||||
get => _fullPath;
|
||||
private set
|
||||
{
|
||||
if (_fullPath.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_fullPath = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string FileName
|
||||
{
|
||||
get => _fileName;
|
||||
private set
|
||||
{
|
||||
if (_fileName == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_fileName = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public int RowNumber
|
||||
{
|
||||
get => _rowNumber;
|
||||
internal set
|
||||
{
|
||||
if (_rowNumber == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rowNumber = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public double SizeMb
|
||||
{
|
||||
get => _sizeMb;
|
||||
private set
|
||||
{
|
||||
if (Math.Abs(_sizeMb - value) < 0.01)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sizeMb = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string AudioSummary
|
||||
{
|
||||
get => _audioSummary;
|
||||
private set
|
||||
{
|
||||
if (_audioSummary == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_audioSummary = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string SubtitleSummary
|
||||
{
|
||||
get => _subtitleSummary;
|
||||
private set
|
||||
{
|
||||
if (_subtitleSummary == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subtitleSummary = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string AttachmentSummary
|
||||
{
|
||||
get => _attachmentSummary;
|
||||
private set
|
||||
{
|
||||
if (_attachmentSummary == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_attachmentSummary = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (_status == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_status = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public double ProgressPercent
|
||||
{
|
||||
get => _progressPercent;
|
||||
set
|
||||
{
|
||||
if (Math.Abs(_progressPercent - value) < 0.01)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_progressPercent = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
set
|
||||
{
|
||||
if (_message == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_message = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public MediaAnalysisResult? MediaAnalysis
|
||||
{
|
||||
get => _mediaAnalysis;
|
||||
private set
|
||||
{
|
||||
if (ReferenceEquals(_mediaAnalysis, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_mediaAnalysis = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public int TotalTracksToExtract
|
||||
{
|
||||
get => _totalTracksToExtract;
|
||||
private set
|
||||
{
|
||||
if (_totalTracksToExtract == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_totalTracksToExtract = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void RefreshPath(string path)
|
||||
{
|
||||
FullPath = path;
|
||||
FileName = Path.GetFileName(path);
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
SizeMb = info.Exists ? Math.Round(info.Length / (1024.0 * 1024.0), 2) : 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
SizeMb = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetForRequeue()
|
||||
{
|
||||
MediaAnalysis = null;
|
||||
TotalTracksToExtract = 0;
|
||||
const string dash = "\u2014";
|
||||
AudioSummary = SubtitleSummary = AttachmentSummary = dash;
|
||||
ProgressPercent = 0;
|
||||
Message = string.Empty;
|
||||
Status = TrackExtractionStatuses.Queued;
|
||||
}
|
||||
|
||||
public void ApplyAnalysisOk(MediaAnalysisResult media)
|
||||
{
|
||||
MediaAnalysis = media;
|
||||
var attachments = media.AllStreams.Count(x => x.Kind == MediaStreamKind.Attachment);
|
||||
AudioSummary = media.AudioStreams.Count.ToString(CultureInfo.InvariantCulture);
|
||||
SubtitleSummary = media.SubtitleStreams.Count.ToString(CultureInfo.InvariantCulture);
|
||||
AttachmentSummary = attachments.ToString(CultureInfo.InvariantCulture);
|
||||
TotalTracksToExtract = media.AudioStreams.Count + media.SubtitleStreams.Count + attachments;
|
||||
Status = TrackExtractionStatuses.Ready;
|
||||
ProgressPercent = 0;
|
||||
Message = string.Empty;
|
||||
}
|
||||
|
||||
public void ApplyAnalysisError(string reason)
|
||||
{
|
||||
MediaAnalysis = null;
|
||||
TotalTracksToExtract = 0;
|
||||
const string dash = "\u2014";
|
||||
AudioSummary = SubtitleSummary = AttachmentSummary = dash;
|
||||
Status = TrackExtractionStatuses.Error;
|
||||
ProgressPercent = 0;
|
||||
Message = reason.Trim();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
20
EmbyToolbox/Models/TrackExtractionStatuses.cs
Normal file
20
EmbyToolbox/Models/TrackExtractionStatuses.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public static class TrackExtractionStatuses
|
||||
{
|
||||
public const string Queued = "В очереди";
|
||||
public const string Analyzing = "Анализ";
|
||||
public const string Ready = "Готов";
|
||||
public const string Working = "В работе";
|
||||
public const string Done = "Готово";
|
||||
public const string Error = "Ошибка";
|
||||
public const string Cancelled = "Отмена";
|
||||
}
|
||||
|
||||
public enum TrackExtractionRunOutcome
|
||||
{
|
||||
None,
|
||||
Success,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
49
EmbyToolbox/Models/TrackSettingsSnapshot.cs
Normal file
49
EmbyToolbox/Models/TrackSettingsSnapshot.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
public sealed class TrackSettingsSnapshot
|
||||
{
|
||||
public string FilePath { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Нормализованный абсолютный каталог исходного видеофайла (родитель имени).</summary>
|
||||
public string ScopeDirectory { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>При добавлении каталогом — корень выбранной папки; при перетаскивании — общий предок каталогов батча.</summary>
|
||||
public string? ScopeBatchRoot { get; init; }
|
||||
|
||||
public TrackStructureSignature Signature { get; init; } = new();
|
||||
public IReadOnlyList<TrackSettingsSnapshotItem> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class TrackStructureSignature
|
||||
{
|
||||
public IReadOnlyList<TrackStructureSignatureItem> Tracks { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class TrackStructureSignatureItem
|
||||
{
|
||||
public int Order { get; init; }
|
||||
public MediaStreamKind StreamKind { get; init; }
|
||||
public SourceKind Source { get; init; }
|
||||
public string Codec { get; init; } = string.Empty;
|
||||
public string Language { get; init; } = string.Empty;
|
||||
public string Title { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class TrackSettingsSnapshotItem
|
||||
{
|
||||
public int Order { get; init; }
|
||||
public MediaStreamKind StreamKind { get; init; }
|
||||
public SourceKind Source { get; init; }
|
||||
public string Codec { get; init; } = string.Empty;
|
||||
public string Language { get; init; } = string.Empty;
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public TrackActionKind Action { get; init; }
|
||||
public string? Bitrate { get; init; }
|
||||
public bool? Default { get; init; }
|
||||
public string? TargetCodec { get; init; }
|
||||
|
||||
/// <summary>True, если Title в UI отличается от типового (ffprobe/filename) — тогда при apply копируем Title.</summary>
|
||||
public bool SnapshotTitleWasUserEdited { get; init; }
|
||||
}
|
||||
40
EmbyToolbox/Models/TrackSnapshotMatching.cs
Normal file
40
EmbyToolbox/Models/TrackSnapshotMatching.cs
Normal file
@ -0,0 +1,40 @@
|
||||
namespace EmbyToolbox.Models;
|
||||
|
||||
/// <summary>Как сопоставлена дорожка текущего файла со snapshot.</summary>
|
||||
public enum MatchingStrategy
|
||||
{
|
||||
None,
|
||||
/// <summary>Type + Source + язык + порядковый номер внутри этой группы (как в текущем файле).</summary>
|
||||
OrdinalByTypeSourceLanguage
|
||||
}
|
||||
|
||||
/// <summary>Результат сопоставления одной дорожки текущего файла.</summary>
|
||||
public sealed class TrackMatchResult
|
||||
{
|
||||
public int CurrentOrder { get; init; }
|
||||
public bool IsMatched { get; init; }
|
||||
public MatchingStrategy Strategy { get; init; }
|
||||
public bool HadAmbiguousCandidates { get; init; }
|
||||
public TrackSettingsSnapshotItem? SourceItem { get; init; }
|
||||
public TrackActionKind ResolvedAction { get; init; }
|
||||
}
|
||||
|
||||
public enum SnapshotApplyDegree
|
||||
{
|
||||
None,
|
||||
Partial,
|
||||
Full
|
||||
}
|
||||
|
||||
public enum SnapshotApplyReason
|
||||
{
|
||||
NoSnapshot,
|
||||
ScopeMismatch,
|
||||
Success
|
||||
}
|
||||
|
||||
public readonly record struct SnapshotApplyResult(
|
||||
bool AppliedAny,
|
||||
SnapshotApplyDegree Degree,
|
||||
SnapshotApplyReason Reason,
|
||||
IReadOnlyList<TrackMatchResult>? TrackResults);
|
||||
BIN
EmbyToolbox/Resources/AppIcon.ico
Normal file
BIN
EmbyToolbox/Resources/AppIcon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
EmbyToolbox/Resources/Icons/icons8-emby-48.png
Normal file
BIN
EmbyToolbox/Resources/Icons/icons8-emby-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1019 B |
BIN
EmbyToolbox/Resources/Icons/icons8-emby-96.png
Normal file
BIN
EmbyToolbox/Resources/Icons/icons8-emby-96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
386
EmbyToolbox/Services/AppSettingsService.cs
Normal file
386
EmbyToolbox/Services/AppSettingsService.cs
Normal file
@ -0,0 +1,386 @@
|
||||
using System.Text.Json;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class AppSettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _settingsFilePath;
|
||||
|
||||
public AppSettingsService()
|
||||
{
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"EmbyToolbox");
|
||||
_settingsFilePath = Path.Combine(appDataDir, "settings.json");
|
||||
}
|
||||
|
||||
/// <summary>Нормализация списка профилей (в т.ч. после загрузки .conv_setup).</summary>
|
||||
public static List<ConversionProfileSettingsEntry> NormalizeStoredConversionProfiles(
|
||||
List<ConversionProfileSettingsEntry>? profiles) =>
|
||||
NormalizeProfiles(profiles);
|
||||
|
||||
public AppSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_settingsFilePath))
|
||||
{
|
||||
var json = File.ReadAllText(_settingsFilePath);
|
||||
var loaded = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions);
|
||||
if (loaded is not null)
|
||||
{
|
||||
loaded.ProcessingTempDirectory = NormalizeTempDirectory(loaded.ProcessingTempDirectory);
|
||||
loaded.MinimumFileLogLevel = NormalizeLogLevel(loaded.MinimumFileLogLevel);
|
||||
loaded.HardwareAcceleration = NormalizeHardwareAcceleration(loaded.HardwareAcceleration);
|
||||
loaded.ConversionProfiles = NormalizeProfiles(loaded.ConversionProfiles);
|
||||
EnsureDirectoryExists(loaded.ProcessingTempDirectory);
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If settings are corrupted or unreadable, fall back to defaults.
|
||||
}
|
||||
|
||||
var defaults = CreateDefaults();
|
||||
EnsureDirectoryExists(defaults.ProcessingTempDirectory);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public void Save(AppSettings settings)
|
||||
{
|
||||
var sanitized = new AppSettings
|
||||
{
|
||||
ProcessingTempDirectory = NormalizeTempDirectory(settings.ProcessingTempDirectory),
|
||||
MinimumFileLogLevel = NormalizeLogLevel(settings.MinimumFileLogLevel),
|
||||
HardwareAcceleration = NormalizeHardwareAcceleration(settings.HardwareAcceleration),
|
||||
ConversionProfiles = NormalizeProfiles(settings.ConversionProfiles),
|
||||
IsLogCollapsed = settings.IsLogCollapsed,
|
||||
NotifyCompletionSoundAfterQueue = settings.NotifyCompletionSoundAfterQueue,
|
||||
NotifyWindowsToastAfterQueue = settings.NotifyWindowsToastAfterQueue,
|
||||
LastSeriesRenamerFolder = settings.LastSeriesRenamerFolder,
|
||||
LastConversionFilesFolder = settings.LastConversionFilesFolder,
|
||||
LastConversionFolder = settings.LastConversionFolder,
|
||||
LastMergeFolder = settings.LastMergeFolder,
|
||||
LastVideoInfoFolder = settings.LastVideoInfoFolder,
|
||||
LastTempFolder = settings.LastTempFolder,
|
||||
LastOutputFolder = settings.LastOutputFolder,
|
||||
LastTrackExtractDestinationFolder = settings.LastTrackExtractDestinationFolder,
|
||||
LastCommonFolder = settings.LastCommonFolder,
|
||||
CopyPreviousTrackSettings = settings.CopyPreviousTrackSettings,
|
||||
DisableSubtitleDefault = settings.DisableSubtitleDefault
|
||||
};
|
||||
|
||||
var settingsDir = Path.GetDirectoryName(_settingsFilePath)!;
|
||||
Directory.CreateDirectory(settingsDir);
|
||||
EnsureDirectoryExists(sanitized.ProcessingTempDirectory);
|
||||
|
||||
var json = JsonSerializer.Serialize(sanitized, JsonOptions);
|
||||
File.WriteAllText(_settingsFilePath, json);
|
||||
}
|
||||
|
||||
private static AppSettings CreateDefaults()
|
||||
{
|
||||
return new AppSettings
|
||||
{
|
||||
ProcessingTempDirectory = NormalizeTempDirectory(null),
|
||||
MinimumFileLogLevel = LogLevel.Info.ToString(),
|
||||
HardwareAcceleration = HardwareAccelerationMode.Auto,
|
||||
IsLogCollapsed = true,
|
||||
CopyPreviousTrackSettings = false,
|
||||
DisableSubtitleDefault = false,
|
||||
NotifyCompletionSoundAfterQueue = true,
|
||||
NotifyWindowsToastAfterQueue = true,
|
||||
ConversionProfiles = CreateDefaultProfiles()
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ConversionProfileSettingsEntry> NormalizeProfiles(List<ConversionProfileSettingsEntry>? profiles)
|
||||
{
|
||||
var defaults = CreateDefaultProfiles();
|
||||
var defaultByName = defaults.ToDictionary(p => p.Profile, StringComparer.OrdinalIgnoreCase);
|
||||
var mergedBuiltIn = new Dictionary<string, ConversionProfileSettingsEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in new[] { "Emby", "Web", "Archive" })
|
||||
{
|
||||
mergedBuiltIn[name] = CloneEntry(defaultByName[name]);
|
||||
}
|
||||
|
||||
var customByName = new Dictionary<string, ConversionProfileSettingsEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (profiles is not null)
|
||||
{
|
||||
foreach (var p in profiles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(p.Profile))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = p.Profile.Trim();
|
||||
if (IsBuiltInProfileName(name))
|
||||
{
|
||||
var def = defaultByName[name];
|
||||
mergedBuiltIn[name] = MergeEntry(def, p);
|
||||
}
|
||||
else
|
||||
{
|
||||
customByName[name] = SanitizeCustomEntry(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<ConversionProfileSettingsEntry>();
|
||||
foreach (var name in new[] { "Emby", "Web", "Archive" })
|
||||
{
|
||||
result.Add(mergedBuiltIn[name]);
|
||||
}
|
||||
|
||||
result.AddRange(customByName.Values.OrderBy(p => p.Profile, StringComparer.CurrentCultureIgnoreCase));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsBuiltInProfileName(string name)
|
||||
{
|
||||
return name.Equals("Emby", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("Web", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("Archive", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ConversionProfileSettingsEntry CloneEntry(ConversionProfileSettingsEntry source)
|
||||
{
|
||||
return new ConversionProfileSettingsEntry
|
||||
{
|
||||
Profile = source.Profile,
|
||||
Container = source.Container,
|
||||
Video = source.Video,
|
||||
PixelFormat = source.PixelFormat,
|
||||
Resolution = source.Resolution,
|
||||
Fps = source.Fps,
|
||||
Audio = source.Audio,
|
||||
Bitrate = source.Bitrate,
|
||||
VideoBitrateMode = source.VideoBitrateMode,
|
||||
VideoBitrateMbps = source.VideoBitrateMbps,
|
||||
Subtitles = source.Subtitles,
|
||||
ExternalTracks = source.ExternalTracks,
|
||||
ExternalSubtitles = source.ExternalSubtitles,
|
||||
Fonts = source.Fonts
|
||||
};
|
||||
}
|
||||
|
||||
private static ConversionProfileSettingsEntry MergeEntry(ConversionProfileSettingsEntry def, ConversionProfileSettingsEntry fromFile)
|
||||
{
|
||||
return new ConversionProfileSettingsEntry
|
||||
{
|
||||
Profile = def.Profile,
|
||||
Container = CoalesceField(fromFile.Container, def.Container),
|
||||
Video = CoalesceField(fromFile.Video, def.Video),
|
||||
PixelFormat = CoalesceField(fromFile.PixelFormat, def.PixelFormat),
|
||||
Resolution = CoalesceField(fromFile.Resolution, def.Resolution),
|
||||
Fps = CoalesceField(fromFile.Fps, def.Fps),
|
||||
Audio = CoalesceField(fromFile.Audio, def.Audio),
|
||||
Bitrate = CoalesceField(fromFile.Bitrate, def.Bitrate),
|
||||
VideoBitrateMode = CoalesceField(fromFile.VideoBitrateMode, def.VideoBitrateMode),
|
||||
VideoBitrateMbps = fromFile.VideoBitrateMbps > 0 ? fromFile.VideoBitrateMbps : def.VideoBitrateMbps,
|
||||
Subtitles = CoalesceField(fromFile.Subtitles, def.Subtitles),
|
||||
ExternalTracks = CoalesceField(fromFile.ExternalTracks, def.ExternalTracks),
|
||||
ExternalSubtitles = CoalesceField(fromFile.ExternalSubtitles, def.ExternalSubtitles),
|
||||
Fonts = CoalesceField(fromFile.Fonts, def.Fonts)
|
||||
};
|
||||
}
|
||||
|
||||
private static string CoalesceField(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
private static ConversionProfileSettingsEntry SanitizeCustomEntry(ConversionProfileSettingsEntry p)
|
||||
{
|
||||
return new ConversionProfileSettingsEntry
|
||||
{
|
||||
Profile = p.Profile.Trim(),
|
||||
Container = p.Container?.Trim() ?? "MKV",
|
||||
Video = p.Video?.Trim() ?? "H.264",
|
||||
PixelFormat = p.PixelFormat?.Trim() ?? "yuv420p",
|
||||
Resolution = p.Resolution?.Trim() ?? "Без изменений",
|
||||
Fps = p.Fps?.Trim() ?? "Без изменений",
|
||||
Audio = p.Audio?.Trim() ?? "AAC",
|
||||
Bitrate = p.Bitrate?.Trim() ?? "256 kbps",
|
||||
VideoBitrateMode = string.IsNullOrWhiteSpace(p.VideoBitrateMode) ? VideoBitratePolicy.Auto : p.VideoBitrateMode.Trim(),
|
||||
VideoBitrateMbps = p.VideoBitrateMbps > 0 ? p.VideoBitrateMbps : null,
|
||||
Subtitles = p.Subtitles?.Trim() ?? "Да",
|
||||
ExternalTracks = p.ExternalTracks?.Trim() ?? "Да",
|
||||
ExternalSubtitles = p.ExternalSubtitles?.Trim() ?? "Да",
|
||||
Fonts = p.Fonts?.Trim() ?? "Нет"
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ConversionProfileSettingsEntry> CreateDefaultProfiles()
|
||||
{
|
||||
return
|
||||
[
|
||||
new()
|
||||
{
|
||||
Profile = "Emby",
|
||||
Container = "MKV",
|
||||
Video = "H.264",
|
||||
PixelFormat = "yuv420p",
|
||||
Resolution = "Без изменений",
|
||||
Fps = "Без изменений",
|
||||
Audio = "AAC",
|
||||
Bitrate = "256 kbps",
|
||||
VideoBitrateMode = VideoBitratePolicy.Auto,
|
||||
Subtitles = "Да",
|
||||
ExternalTracks = "Да",
|
||||
ExternalSubtitles = "Да",
|
||||
Fonts = "Да"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Profile = "Web",
|
||||
Container = "MP4",
|
||||
Video = "H.264",
|
||||
PixelFormat = "yuv420p",
|
||||
Resolution = "Без изменений",
|
||||
Fps = "Без изменений",
|
||||
Audio = "AAC",
|
||||
Bitrate = "256 kbps",
|
||||
VideoBitrateMode = "8 Mbps",
|
||||
Subtitles = "Да",
|
||||
ExternalTracks = "Да",
|
||||
ExternalSubtitles = "Да",
|
||||
Fonts = "Нет"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Profile = "Archive",
|
||||
Container = "MP4",
|
||||
Video = "H.264",
|
||||
PixelFormat = "yuv420p",
|
||||
Resolution = "Максимум 1080p",
|
||||
Fps = "Максимум 30",
|
||||
Audio = "AAC",
|
||||
Bitrate = "256 kbps",
|
||||
VideoBitrateMode = "4 Mbps",
|
||||
Subtitles = "Да",
|
||||
ExternalTracks = "Да",
|
||||
ExternalSubtitles = "Да",
|
||||
Fonts = "Нет"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static string NormalizeTempDirectory(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Path.Combine(Path.GetTempPath(), "EmbyToolbox");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static void EnsureDirectoryExists(string path)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeLogLevel(string? value)
|
||||
{
|
||||
if (Enum.TryParse<LogLevel>(value, ignoreCase: true, out var level))
|
||||
{
|
||||
return level.ToString();
|
||||
}
|
||||
|
||||
return LogLevel.Info.ToString();
|
||||
}
|
||||
|
||||
private static string NormalizeHardwareAcceleration(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return HardwareAccelerationMode.Auto;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
return normalized switch
|
||||
{
|
||||
HardwareAccelerationMode.Auto => HardwareAccelerationMode.Auto,
|
||||
HardwareAccelerationMode.Nvenc => HardwareAccelerationMode.Nvenc,
|
||||
HardwareAccelerationMode.Qsv => HardwareAccelerationMode.Qsv,
|
||||
HardwareAccelerationMode.Amf => HardwareAccelerationMode.Amf,
|
||||
HardwareAccelerationMode.Cpu => HardwareAccelerationMode.Cpu,
|
||||
_ => HardwareAccelerationMode.Auto
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record AppSettings
|
||||
{
|
||||
public string ProcessingTempDirectory { get; set; } = Path.Combine(Path.GetTempPath(), "EmbyToolbox");
|
||||
public string MinimumFileLogLevel { get; set; } = LogLevel.Info.ToString();
|
||||
public string HardwareAcceleration { get; set; } = HardwareAccelerationMode.Auto;
|
||||
public bool IsLogCollapsed { get; set; } = true;
|
||||
public List<ConversionProfileSettingsEntry> ConversionProfiles { get; set; } = [];
|
||||
|
||||
public string? LastSeriesRenamerFolder { get; set; }
|
||||
public string? LastConversionFilesFolder { get; set; }
|
||||
public string? LastConversionFolder { get; set; }
|
||||
public string? LastMergeFolder { get; set; }
|
||||
public string? LastVideoInfoFolder { get; set; }
|
||||
public string? LastTempFolder { get; set; }
|
||||
public string? LastOutputFolder { get; set; }
|
||||
public string? LastTrackExtractDestinationFolder { get; set; }
|
||||
public string? LastCommonFolder { get; set; }
|
||||
|
||||
/// <summary>Конвертация: применять сохранённый snapshot дорожек с предыдущего настроенного файла.</summary>
|
||||
public bool CopyPreviousTrackSettings { get; set; }
|
||||
|
||||
/// <summary>Конвертация: выключать default у всех subtitle-дорожек.</summary>
|
||||
public bool DisableSubtitleDefault { get; set; }
|
||||
|
||||
/// <summary>После завершения очереди конвертации воспроизводить системный звук Windows.</summary>
|
||||
public bool NotifyCompletionSoundAfterQueue { get; set; } = true;
|
||||
|
||||
/// <summary>После завершения очереди конвертации показывать уведомление Windows.</summary>
|
||||
public bool NotifyWindowsToastAfterQueue { get; set; } = true;
|
||||
}
|
||||
|
||||
public static class HardwareAccelerationMode
|
||||
{
|
||||
public const string Auto = "Auto";
|
||||
public const string Nvenc = "NVENC";
|
||||
public const string Qsv = "QSV";
|
||||
public const string Amf = "AMF";
|
||||
public const string Cpu = "CPU";
|
||||
}
|
||||
|
||||
public sealed record ConversionProfileSettingsEntry
|
||||
{
|
||||
public string Profile { get; set; } = string.Empty;
|
||||
public string Container { get; set; } = "MKV";
|
||||
public string Video { get; set; } = "H.264";
|
||||
public string PixelFormat { get; set; } = "yuv420p";
|
||||
public string Resolution { get; set; } = "Без изменений";
|
||||
public string Fps { get; set; } = "Без изменений";
|
||||
public string Audio { get; set; } = "AAC";
|
||||
public string Bitrate { get; set; } = "256 kbps";
|
||||
public string VideoBitrateMode { get; set; } = VideoBitratePolicy.Auto;
|
||||
public double? VideoBitrateMbps { get; set; }
|
||||
public string Subtitles { get; set; } = "Да";
|
||||
public string ExternalTracks { get; set; } = "Да";
|
||||
public string ExternalSubtitles { get; set; } = "Да";
|
||||
public string Fonts { get; set; } = "Нет";
|
||||
}
|
||||
74
EmbyToolbox/Services/BulkTrackSettingsService.cs
Normal file
74
EmbyToolbox/Services/BulkTrackSettingsService.cs
Normal file
@ -0,0 +1,74 @@
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class BulkTrackSettingsService
|
||||
{
|
||||
private readonly TrackStructureComparer _comparer = new();
|
||||
|
||||
public BulkTrackSelectionAnalysis Analyze(IReadOnlyList<ConversionQueueItem> selected)
|
||||
{
|
||||
var candidates = selected
|
||||
.Where(i => i.MediaAnalysis is not null && i.TaskOverride.TrackOverrides.Count > 0)
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count < 2)
|
||||
{
|
||||
return BulkTrackSelectionAnalysis.Empty;
|
||||
}
|
||||
|
||||
var groups = candidates
|
||||
.GroupBy(i => _comparer.BuildSignature(i.TaskOverride.TrackOverrides))
|
||||
.Select(g => new { Signature = g.Key, Items = g.OrderBy(i => i.OrderNumber).ToList() })
|
||||
.OrderByDescending(g => g.Items.Count)
|
||||
.ThenBy(g => g.Items[0].OrderNumber)
|
||||
.ToList();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
return BulkTrackSelectionAnalysis.Empty;
|
||||
}
|
||||
|
||||
var top = groups[0];
|
||||
if (groups.Count > 1 && groups[1].Items.Count == top.Items.Count)
|
||||
{
|
||||
return new BulkTrackSelectionAnalysis(null, [], candidates.OrderBy(i => i.OrderNumber).ToList(), false);
|
||||
}
|
||||
|
||||
var skipped = candidates.Except(top.Items).OrderBy(i => i.OrderNumber).ToList();
|
||||
return new BulkTrackSelectionAnalysis(top.Signature, top.Items, skipped, true);
|
||||
}
|
||||
|
||||
public void ApplyBulkEdits(
|
||||
IReadOnlyList<ConversionQueueItem> targets,
|
||||
IReadOnlyList<TrackOverrideEntry> editedTemplateTracks)
|
||||
{
|
||||
foreach (var item in targets)
|
||||
{
|
||||
if (item.TaskOverride.TrackOverrides.Count != editedTemplateTracks.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < editedTemplateTracks.Count; i++)
|
||||
{
|
||||
var dst = item.TaskOverride.TrackOverrides[i];
|
||||
var src = editedTemplateTracks[i];
|
||||
dst.Action = src.Action;
|
||||
dst.AudioBitrateKbps = src.AudioBitrateKbps;
|
||||
dst.Default = src.Default;
|
||||
dst.Language = src.Language;
|
||||
dst.Title = src.Title;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BulkTrackSelectionAnalysis(
|
||||
string? MajoritySignature,
|
||||
IReadOnlyList<ConversionQueueItem> MajorityItems,
|
||||
IReadOnlyList<ConversionQueueItem> SkippedItems,
|
||||
bool HasMajority)
|
||||
{
|
||||
public static BulkTrackSelectionAnalysis Empty { get; } = new(null, [], [], false);
|
||||
}
|
||||
42
EmbyToolbox/Services/ChapterBuilderService.cs
Normal file
42
EmbyToolbox/Services/ChapterBuilderService.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System.Text;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class ChapterBuilderService
|
||||
{
|
||||
public string BuildFfmetadata(IReadOnlyList<string> chapterTitles, IReadOnlyList<double> durationsSeconds)
|
||||
{
|
||||
if (chapterTitles.Count != durationsSeconds.Count)
|
||||
{
|
||||
throw new ArgumentException("Количество глав не совпадает с количеством длительностей.");
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(";FFMETADATA1");
|
||||
long currentStartMs = 0;
|
||||
for (var i = 0; i < chapterTitles.Count; i++)
|
||||
{
|
||||
var durationMs = Math.Max(1L, (long)Math.Round(Math.Max(0.001, durationsSeconds[i]) * 1000.0, MidpointRounding.AwayFromZero));
|
||||
var endMs = currentStartMs + durationMs - 1;
|
||||
var title = string.IsNullOrWhiteSpace(chapterTitles[i]) ? $"Часть {i + 1}" : chapterTitles[i].Trim();
|
||||
sb.AppendLine("[CHAPTER]");
|
||||
sb.AppendLine("TIMEBASE=1/1000");
|
||||
sb.AppendLine($"START={currentStartMs}");
|
||||
sb.AppendLine($"END={endMs}");
|
||||
sb.AppendLine($"title={EscapeMetadataValue(title)}");
|
||||
currentStartMs += durationMs;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string EscapeMetadataValue(string value) =>
|
||||
value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("=", "\\=", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace("#", "\\#", StringComparison.Ordinal)
|
||||
.Replace(Environment.NewLine, " ", StringComparison.Ordinal)
|
||||
.Replace("\n", " ", StringComparison.Ordinal)
|
||||
.Replace("\r", " ", StringComparison.Ordinal);
|
||||
}
|
||||
754
EmbyToolbox/Services/ConversionExecutionService.cs
Normal file
754
EmbyToolbox/Services/ConversionExecutionService.cs
Normal file
@ -0,0 +1,754 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class ConversionExecutionService
|
||||
{
|
||||
private readonly LoggingService _logging;
|
||||
private readonly FfmpegCommandBuilder _builder;
|
||||
private readonly FfmpegService _ffmpeg;
|
||||
private readonly FfprobeService _ffprobe;
|
||||
private readonly FfmpegEncoderDiscoveryService _encoderDiscovery;
|
||||
private readonly Func<string> _resolveHardwareAcceleration;
|
||||
private readonly SafeFileReplaceService _replace;
|
||||
private readonly ExternalFileCleanupService _cleanup;
|
||||
|
||||
public ConversionExecutionService(
|
||||
LoggingService logging,
|
||||
FfmpegCommandBuilder builder,
|
||||
FfmpegService ffmpeg,
|
||||
FfprobeService ffprobe,
|
||||
FfmpegEncoderDiscoveryService encoderDiscovery,
|
||||
Func<string> resolveHardwareAcceleration,
|
||||
SafeFileReplaceService replace,
|
||||
ExternalFileCleanupService cleanup)
|
||||
{
|
||||
_logging = logging;
|
||||
_builder = builder;
|
||||
_ffmpeg = ffmpeg;
|
||||
_ffprobe = ffprobe;
|
||||
_encoderDiscovery = encoderDiscovery;
|
||||
_resolveHardwareAcceleration = resolveHardwareAcceleration;
|
||||
_replace = replace;
|
||||
_cleanup = cleanup;
|
||||
}
|
||||
|
||||
public async Task RunQueueAsync(
|
||||
IReadOnlyList<ConversionQueueItem> items,
|
||||
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
|
||||
string? tempRoot,
|
||||
string runId,
|
||||
Func<Action, Task> uiInvoke,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logging.Info($"старт обработки очереди: {items.Count}", "conversion.exec");
|
||||
_ = _encoderDiscovery.GetAvailableEncoders(_logging);
|
||||
var root = EnsureTempRoot(tempRoot);
|
||||
foreach (var item in items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (item.Status is not (ConversionQueueStatus.Ready or ConversionQueueStatus.Pending))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await RunSingleAsync(item, resolveProfile, root, runId, uiInvoke, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunSingleAsync(
|
||||
ConversionQueueItem item,
|
||||
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
|
||||
string tempRoot,
|
||||
string runId,
|
||||
Func<Action, Task> uiInvoke,
|
||||
CancellationToken token)
|
||||
{
|
||||
var tempOut = string.Empty;
|
||||
var finalPath = item.FullPath;
|
||||
var targetContainer = string.Empty;
|
||||
FfmpegCommand? ffmpegCmdForDisposableDumps = null;
|
||||
try
|
||||
{
|
||||
if (item.IsSkipPlan)
|
||||
{
|
||||
await uiInvoke(
|
||||
() =>
|
||||
{
|
||||
item.Status = ConversionQueueStatus.Done;
|
||||
item.Progress = 100;
|
||||
item.IsProcessed = true;
|
||||
item.ProcessedInCurrentRun = true;
|
||||
item.LastRunId = runId;
|
||||
item.ErrorMessage = null;
|
||||
item.ErrorDetails = null;
|
||||
}).ConfigureAwait(false);
|
||||
_logging.Info($"Файл пропущен: обработка не требуется. {item.FullPath}", "conversion.exec");
|
||||
return;
|
||||
}
|
||||
|
||||
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.{Guid.NewGuid():N}.tmp{Path.GetExtension(item.FileName)}");
|
||||
await uiInvoke(() =>
|
||||
{
|
||||
item.Status = ConversionQueueStatus.Running;
|
||||
item.Progress = 0;
|
||||
item.IsProcessed = true;
|
||||
item.ProcessedInCurrentRun = false;
|
||||
item.LastRunId = runId;
|
||||
item.ErrorMessage = null;
|
||||
item.ErrorDetails = null;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var profile = resolveProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||||
targetContainer = ResolveTargetContainer(item, profile);
|
||||
finalPath = BuildFinalPath(item.FullPath, targetContainer);
|
||||
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.__processing__{GetOutputExtension(targetContainer)}");
|
||||
var selectedAcceleration = _resolveHardwareAcceleration();
|
||||
var requiresVideoTranscode = RequiresVideoTranscode(item, profile);
|
||||
var usedTsTimestampRetryTranscode = false;
|
||||
|
||||
if (MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName))
|
||||
{
|
||||
_logging.Info(
|
||||
"MPEG-TS: включено исправление временных меток (genpts; avoid_negative_ts; muxdelay/muxpreload)",
|
||||
"conversion.exec");
|
||||
if (item.LastPlan?.RequiresTimestampFix == true)
|
||||
{
|
||||
_logging.Info(
|
||||
"План: MPEG-TS → MKV — режим коррекции timestamps (при ошибке copy видео будет перекодирование)",
|
||||
"conversion.exec");
|
||||
}
|
||||
}
|
||||
|
||||
var videoEncoder = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, requiresVideoTranscode);
|
||||
var cmd = _builder.Build(item, profile, tempOut, videoEncoder, requiresVideoTranscode);
|
||||
ffmpegCmdForDisposableDumps = cmd;
|
||||
if (!string.Equals(Path.GetExtension(item.FullPath), Path.GetExtension(finalPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logging.Info($"Remux: {Path.GetExtension(item.FullPath).TrimStart('.').ToUpperInvariant()} -> {Path.GetExtension(finalPath).TrimStart('.').ToUpperInvariant()}", "conversion.exec");
|
||||
}
|
||||
_logging.Info($"Начало обработки файла: {item.FullPath}", "conversion.exec");
|
||||
_logging.Info($"План:{Environment.NewLine}{item.PlanSummary}", "conversion.exec");
|
||||
_logging.Info($"Временный файл результата: {tempOut}", "conversion.exec");
|
||||
_logging.Info($"Финальный файл: {finalPath}", "conversion.exec");
|
||||
LogVideoEncodeSession(item, profile, cmd, videoEncoder);
|
||||
|
||||
LogPlannedActions(item, profile, cmd, requiresVideoTranscode);
|
||||
_logging.Info("FFmpeg: старт объединенной обработки файла", "conversion.exec", command: cmd.FullCommand);
|
||||
var ffProgress = new Progress<FfmpegProgressSnapshot>(
|
||||
p =>
|
||||
{
|
||||
_ = uiInvoke(
|
||||
() =>
|
||||
{
|
||||
if (p.IsIndeterminate)
|
||||
{
|
||||
if (item.Progress < 1)
|
||||
{
|
||||
item.Progress = 1;
|
||||
}
|
||||
}
|
||||
else if (p.Percent is { } encPct)
|
||||
{
|
||||
// 0..99% ffmpeg → очередь 0..89 (floor); ffmpeg 100% (progress=end) → 90
|
||||
var mapped = encPct >= 100
|
||||
? 90
|
||||
: Math.Clamp((int)Math.Floor(encPct / 100.0 * 90.0), 0, 89);
|
||||
item.Progress = mapped;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
|
||||
if (!result.Success
|
||||
&& !requiresVideoTranscode
|
||||
&& MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName)
|
||||
&& MpegTsTimestampHelpers.LooksLikeTimestampMuxFailure(result.StdErr))
|
||||
{
|
||||
_logging.Warning(
|
||||
"Video copy failed due to missing timestamps, retrying with video transcode",
|
||||
"conversion.exec",
|
||||
stderr: result.StdErr);
|
||||
_logging.Info(
|
||||
"MPEG-TS: повтор с перекодированием видео из-за ошибки временных меток",
|
||||
"conversion.exec");
|
||||
|
||||
TryDeleteDisposableAttachmentDumpFiles(cmd);
|
||||
TryDeleteTemp(tempOut);
|
||||
tempOut = Path.Combine(
|
||||
tempRoot,
|
||||
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryTs__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
|
||||
|
||||
usedTsTimestampRetryTranscode = true;
|
||||
var videoEncoderRetry = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, true);
|
||||
cmd = _builder.Build(item, profile, tempOut, videoEncoderRetry, requiresVideoTranscode: false, forceVideoTranscode: true);
|
||||
ffmpegCmdForDisposableDumps = cmd;
|
||||
_logging.Info($"Новый временный файл (повтор): {tempOut}", "conversion.exec");
|
||||
LogVideoEncodeSession(item, profile, cmd, videoEncoderRetry);
|
||||
|
||||
_logging.Info("FFmpeg: повтор с перекодированием видео", "conversion.exec", command: cmd.FullCommand);
|
||||
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!result.Success
|
||||
&& requiresVideoTranscode
|
||||
&& CommandArgumentsUseNvencVideo(cmd.ArgumentList)
|
||||
&& LooksLikeNvencPipelineFailure(result.StdErr))
|
||||
{
|
||||
_logging.Warning(
|
||||
"NVENC failed, retrying with libx264",
|
||||
"conversion.exec",
|
||||
stderr: result.StdErr);
|
||||
TryDeleteDisposableAttachmentDumpFiles(cmd);
|
||||
TryDeleteTemp(tempOut);
|
||||
|
||||
tempOut = Path.Combine(
|
||||
tempRoot,
|
||||
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryCpuNv__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
|
||||
|
||||
var cpuFallback = FfmpegCommandBuilder.CreateCpuFallbackVideoEncoder(item, profile);
|
||||
cmd = _builder.Build(item, profile, tempOut, cpuFallback, requiresVideoTranscode);
|
||||
ffmpegCmdForDisposableDumps = cmd;
|
||||
|
||||
LogVideoEncodeSession(item, profile, cmd, cpuFallback);
|
||||
_logging.Info($"Новый временный файл (NVENC→CPU): {tempOut}", "conversion.exec");
|
||||
_logging.Info("FFmpeg: повтор после сбоя NVENC (CPU кодер)", "conversion.exec", command: cmd.FullCommand);
|
||||
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var err = string.IsNullOrWhiteSpace(result.StdErr) ? $"exit={result.ExitCode}" : ShortenForLog(result.StdErr, 2000);
|
||||
_logging.Error($"FFmpeg завершен с ошибкой: {err}", "conversion.exec", stderr: result.StdErr);
|
||||
var brief = string.IsNullOrWhiteSpace(result.StdErr)
|
||||
? $"ffmpeg завершился с кодом {result.ExitCode}."
|
||||
: ShortenForUiOneLine(result.StdErr.Trim(), 480);
|
||||
var detail = string.IsNullOrWhiteSpace(result.StdErr) ? null : result.StdErr.Trim();
|
||||
TryDeleteTemp(tempOut);
|
||||
await uiInvoke(() =>
|
||||
{
|
||||
item.Status = ConversionQueueStatus.Error;
|
||||
item.ErrorMessage = brief;
|
||||
item.ErrorDetails = detail;
|
||||
item.ProcessedInCurrentRun = false;
|
||||
}).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
_logging.Info("FFmpeg завершен успешно", "conversion.exec");
|
||||
await ValidateOutputAsync(tempOut, item, profile, requiresVideoTranscode || usedTsTimestampRetryTranscode, token).ConfigureAwait(false);
|
||||
|
||||
await uiInvoke(() =>
|
||||
{
|
||||
item.Status = ConversionQueueStatus.Copying;
|
||||
item.Progress = 90;
|
||||
}).ConfigureAwait(false);
|
||||
_logging.Info("Начато копирование результата", "conversion.exec");
|
||||
|
||||
var sameRoot = string.Equals(Path.GetPathRoot(finalPath), Path.GetPathRoot(tempOut), StringComparison.OrdinalIgnoreCase);
|
||||
if (sameRoot)
|
||||
{
|
||||
await uiInvoke(() =>
|
||||
{
|
||||
item.Status = ConversionQueueStatus.Replacing;
|
||||
}).ConfigureAwait(false);
|
||||
_logging.Info("Замена исходного файла", "conversion.exec");
|
||||
}
|
||||
|
||||
var replaceProgress = new Progress<int>(
|
||||
p =>
|
||||
{
|
||||
_ = uiInvoke(() => item.Progress = p);
|
||||
});
|
||||
await _replace.ReplaceAsync(item.FullPath, finalPath, tempOut, replaceProgress, token).ConfigureAwait(false);
|
||||
_logging.Info("успешная замена исходника", "conversion.exec");
|
||||
if (!string.Equals(item.FullPath, finalPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logging.Info("Старый файл удален после успешной замены", "conversion.exec");
|
||||
}
|
||||
|
||||
var usedFonts = cmd.UsedExternalFiles
|
||||
.Where(x => x.IsFont)
|
||||
.DistinctBy(x => x.FullPath)
|
||||
.Count();
|
||||
if (usedFonts > 0)
|
||||
{
|
||||
_logging.Info($"Использованы шрифты: {usedFonts}", "conversion.exec");
|
||||
_logging.Info("Шрифты оставлены на месте", "conversion.exec");
|
||||
}
|
||||
|
||||
var moved = _cleanup.MoveUsedToUseless(finalPath, cmd.UsedExternalFiles);
|
||||
if (moved.Count > 0)
|
||||
{
|
||||
_logging.Info($"Перемещены внешние файлы в useless: {moved.Count}", "conversion.exec");
|
||||
}
|
||||
|
||||
await uiInvoke(() =>
|
||||
{
|
||||
item.UpdateOutputPath(finalPath, targetContainer);
|
||||
item.Progress = 100;
|
||||
item.Status = ConversionQueueStatus.Done;
|
||||
item.ProcessedInCurrentRun = true;
|
||||
}).ConfigureAwait(false);
|
||||
_logging.Info($"Итоговый путь обновлен: {finalPath}", "conversion.exec");
|
||||
_logging.Info("Файл обработан успешно", "conversion.exec");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logging.Warning("отмена пользователем", "conversion.exec");
|
||||
TryDeleteTemp(tempOut);
|
||||
await uiInvoke(() =>
|
||||
{
|
||||
item.Status = ConversionQueueStatus.Cancelled;
|
||||
item.ErrorMessage = "Отмена пользователем";
|
||||
item.ErrorDetails = null;
|
||||
}).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryDeleteTemp(tempOut);
|
||||
_logging.Error($"ошибка обработки: {ex.Message}", "conversion.exec", ex);
|
||||
await uiInvoke(() =>
|
||||
{
|
||||
item.Status = ConversionQueueStatus.Error;
|
||||
item.ErrorMessage = ex.Message;
|
||||
item.ErrorDetails = null;
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDisposableAttachmentDumpFiles(ffmpegCmdForDisposableDumps);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDisposableAttachmentDumpFiles(FfmpegCommand? cmd)
|
||||
{
|
||||
if (cmd?.DisposableAttachmentDumpPaths is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in cmd.DisposableAttachmentDumpPaths.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// временные дампы для -attach после завершения ffmpeg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPlannedActions(ConversionQueueItem item, ConversionProfileSettingsEntry profile, FfmpegCommand cmd, bool requiresVideoTranscode)
|
||||
{
|
||||
var ovr = item.TaskOverride;
|
||||
var targetContainer = string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer;
|
||||
var sourceContainer = item.MediaAnalysis?.ContainerFormat ?? "unknown";
|
||||
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logging.Info($"FFmpeg: старт remux контейнера {sourceContainer} -> {targetContainer}", "conversion.exec");
|
||||
}
|
||||
|
||||
var sourceVideo = item.MediaAnalysis?.PrimaryVideo?.CodecName ?? "unknown";
|
||||
var targetVideo = string.IsNullOrWhiteSpace(ovr.TargetVideo) ? profile.Video : ovr.TargetVideo;
|
||||
if (requiresVideoTranscode || ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert))
|
||||
{
|
||||
_logging.Info($"FFmpeg: старт конвертации видео {sourceVideo} -> {targetVideo}", "conversion.exec");
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("FFmpeg команда для файла:");
|
||||
var mappedLines = 0;
|
||||
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine($"- remux в {targetContainer}");
|
||||
mappedLines++;
|
||||
}
|
||||
if (!requiresVideoTranscode && ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Keep))
|
||||
{
|
||||
sb.AppendLine("- video copy");
|
||||
mappedLines++;
|
||||
}
|
||||
|
||||
foreach (var t in ovr.TrackOverrides.OrderBy(x => x.StreamKind).ThenBy(x => x.StreamIndex))
|
||||
{
|
||||
var src = GetSourceStream(item, t);
|
||||
var lang = FormatLang(src?.Language ?? t.Language);
|
||||
if (t.Action == TrackActionKind.Convert && t.StreamKind == MediaStreamKind.Audio)
|
||||
{
|
||||
var fromCodec = src?.CodecName ?? "unknown";
|
||||
var bitrate = string.IsNullOrWhiteSpace(t.AudioBitrateKbps) ? (string.IsNullOrWhiteSpace(ovr.TargetAudioBitrate) ? profile.Bitrate : ovr.TargetAudioBitrate) : t.AudioBitrateKbps!;
|
||||
_logging.Info($"FFmpeg: старт конвертации audio #{DisplayIndex(t)} {fromCodec} -> aac {bitrate}", "conversion.exec");
|
||||
sb.AppendLine($"- audio #{DisplayIndex(t)} convert to AAC {bitrate}");
|
||||
mappedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t.Action == TrackActionKind.Remove && t.Source == SourceKind.Embedded && t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle)
|
||||
{
|
||||
var kind = t.StreamKind == MediaStreamKind.Audio ? "audio" : "subtitle";
|
||||
var subKind = string.Empty;
|
||||
if (t.StreamKind == MediaStreamKind.Subtitle &&
|
||||
SubtitleCodecRules.IsTeletext(src?.CodecName))
|
||||
{
|
||||
subKind = " (teletext)";
|
||||
}
|
||||
|
||||
_logging.Info($"FFmpeg: старт удаления дорожки {kind}{subKind} #{DisplayIndex(t)} ({lang})", "conversion.exec");
|
||||
sb.AppendLine(subKind.Length > 0
|
||||
? $"- remove teletext subtitle #{DisplayIndex(t)}"
|
||||
: $"- remove {kind} #{DisplayIndex(t)}");
|
||||
mappedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||||
{
|
||||
_logging.Info($"FFmpeg: старт добавления внешней аудиодорожки: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
|
||||
sb.AppendLine("- add external audio");
|
||||
mappedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Subtitle && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||||
{
|
||||
_logging.Info($"FFmpeg: старт добавления внешних субтитров: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
|
||||
sb.AppendLine("- add external subtitle");
|
||||
mappedLines++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var fonts = cmd.UsedExternalFiles.Where(f => f.IsFont).DistinctBy(f => f.FullPath).Count();
|
||||
if (fonts > 0)
|
||||
{
|
||||
_logging.Info($"FFmpeg: старт встраивания шрифтов: {fonts} файлов", "conversion.exec");
|
||||
sb.AppendLine($"- attach fonts: {fonts}");
|
||||
mappedLines++;
|
||||
}
|
||||
|
||||
if (mappedLines == 0)
|
||||
{
|
||||
sb.AppendLine("- copy streams");
|
||||
}
|
||||
|
||||
_logging.Info(sb.ToString().TrimEnd(), "conversion.exec");
|
||||
}
|
||||
|
||||
private static MediaStreamInfo? GetSourceStream(ConversionQueueItem item, TrackOverrideEntry t)
|
||||
{
|
||||
if (t.Source != SourceKind.Embedded || t.StreamIndex < 0 || item.MediaAnalysis is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.MediaAnalysis.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
|
||||
}
|
||||
|
||||
private static int DisplayIndex(TrackOverrideEntry t) => t.StreamIndex >= 0 ? t.StreamIndex + 1 : -1;
|
||||
|
||||
private static string FormatLang(string? lang) => string.IsNullOrWhiteSpace(lang) ? "unknown" : lang.Trim();
|
||||
|
||||
private static string ShortenForUiOneLine(string text, int maxLen)
|
||||
{
|
||||
var one = text.ReplaceLineEndings(" ").Trim();
|
||||
while (one.Contains(" ", StringComparison.Ordinal))
|
||||
{
|
||||
one = one.Replace(" ", " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return ShortenForLog(one, maxLen);
|
||||
}
|
||||
|
||||
private static string ShortenForLog(string text, int maxLen)
|
||||
{
|
||||
if (text.Length <= maxLen)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
return text[..maxLen] + "...";
|
||||
}
|
||||
|
||||
private static string EnsureTempRoot(string? path)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(path)
|
||||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EmbyToolbox", "Temp")
|
||||
: path.Trim();
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void TryDeleteTemp(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveTargetContainer(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
|
||||
{
|
||||
var target = string.IsNullOrWhiteSpace(item.TaskOverride.TargetContainer) ? profile.Container : item.TaskOverride.TargetContainer;
|
||||
return string.IsNullOrWhiteSpace(target) ? profile.Container : target.Trim();
|
||||
}
|
||||
|
||||
private static string BuildFinalPath(string sourcePath, string targetContainer)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(sourcePath) ?? string.Empty;
|
||||
var name = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
return Path.Combine(dir, name + GetOutputExtension(targetContainer));
|
||||
}
|
||||
|
||||
private static string GetOutputExtension(string targetContainer)
|
||||
{
|
||||
if (targetContainer.Contains("mkv", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("matro", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mkv";
|
||||
}
|
||||
|
||||
if (targetContainer.Contains("mp4", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("mov", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
return ".mkv";
|
||||
}
|
||||
|
||||
private VideoEncoderSettings? ResolveVideoEncoderForItem(
|
||||
ConversionQueueItem item,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
string selectedAcceleration,
|
||||
bool requiresVideoTranscode)
|
||||
{
|
||||
if (!requiresVideoTranscode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo)
|
||||
? profile.Video
|
||||
: item.TaskOverride.TargetVideo;
|
||||
return _encoderDiscovery.ResolveVideoEncoder(
|
||||
selectedAcceleration,
|
||||
targetVideo,
|
||||
autoFallbackToCpu: true,
|
||||
_logging);
|
||||
}
|
||||
|
||||
private static bool RequiresVideoTranscode(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
|
||||
{
|
||||
if (item.MediaAnalysis is null)
|
||||
{
|
||||
return item.TaskOverride.TrackOverrides.Any(t =>
|
||||
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
|
||||
}
|
||||
|
||||
return ConversionPlanService.RequiresVideoTranscode(item.MediaAnalysis, profile, item.TaskOverride)
|
||||
|| item.TaskOverride.TrackOverrides.Any(t =>
|
||||
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
|
||||
}
|
||||
|
||||
private async Task ValidateOutputAsync(
|
||||
string outputPath,
|
||||
ConversionQueueItem item,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
bool requiresVideoTranscode,
|
||||
CancellationToken token)
|
||||
{
|
||||
if (!requiresVideoTranscode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var probe = await _ffprobe.AnalyzeAsync(outputPath, token).ConfigureAwait(false);
|
||||
if (!probe.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException("ffprobe результата завершился с ошибкой");
|
||||
}
|
||||
|
||||
var parsed = MediaAnalysisParser.TryParse(probe.Json);
|
||||
var video = parsed?.PrimaryVideo;
|
||||
if (video is null)
|
||||
{
|
||||
throw new InvalidOperationException("в результирующем файле не найден видеопоток");
|
||||
}
|
||||
|
||||
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
|
||||
var expectedCodec = targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase)
|
||||
|| targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase)
|
||||
? "hevc"
|
||||
: "h264";
|
||||
var actualCodec = video.CodecName ?? string.Empty;
|
||||
var codecOk = expectedCodec == "h264"
|
||||
? actualCodec.Contains("h264", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("avc", StringComparison.OrdinalIgnoreCase)
|
||||
: actualCodec.Contains("hevc", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("h265", StringComparison.OrdinalIgnoreCase);
|
||||
if (!codecOk)
|
||||
{
|
||||
throw new InvalidOperationException($"проверка результата не пройдена: video codec={actualCodec}, ожидается {expectedCodec}");
|
||||
}
|
||||
|
||||
var targetPixelUi = string.IsNullOrWhiteSpace(item.TaskOverride.TargetPixelFormat)
|
||||
? profile.PixelFormat
|
||||
: item.TaskOverride.TargetPixelFormat;
|
||||
|
||||
var expectedNorm = ResolveExpectedOutputPixNorm(targetPixelUi, expectedCodec);
|
||||
if (expectedNorm is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var actualNorm = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(video.PixelFormat);
|
||||
if (string.IsNullOrWhiteSpace(actualNorm))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"проверка результата не пройдена: pixel format недоступен (ffprobe: {video.PixelFormat ?? "(null)"})");
|
||||
}
|
||||
|
||||
if (!PixelFormatsMatchForValidation(expectedNorm, actualNorm))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"проверка результата не пройдена: pix_fmt={video.PixelFormat}, ожидается {expectedNorm}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPixelFormatUiOpen(string? s) =>
|
||||
!string.IsNullOrWhiteSpace(s)
|
||||
&& !s.Contains("без", StringComparison.OrdinalIgnoreCase)
|
||||
&& !s.Contains("No change", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Ожидаемый нормализованный pix_fmt для проверки; с «без изменений» типичное yuv420p для AVC/HEVC.</summary>
|
||||
private static string? ResolveExpectedOutputPixNorm(string mergedPixelFmtUiFromProfile, string expectedCodecFourccStyle)
|
||||
{
|
||||
if (IsPixelFormatUiOpen(mergedPixelFmtUiFromProfile))
|
||||
{
|
||||
return FfmpegCommandBuilder.NormalizeFfprobePixelFormat(mergedPixelFmtUiFromProfile!.Trim());
|
||||
}
|
||||
|
||||
if (expectedCodecFourccStyle.Equals("hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| expectedCodecFourccStyle.Equals("h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "yuv420p";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void LogVideoEncodeSession(
|
||||
ConversionQueueItem item,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
FfmpegCommand cmd,
|
||||
VideoEncoderSettings? encoderHint)
|
||||
{
|
||||
if (cmd.VideoEncoderForLog is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pv = item.MediaAnalysis?.PrimaryVideo;
|
||||
var srcPix = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(pv?.PixelFormat) ?? "?";
|
||||
var srcCodec = string.IsNullOrWhiteSpace(pv?.CodecName) ? "?" : pv.CodecName;
|
||||
var mergedPxUi = FfmpegCommandBuilder.ResolveMergedTargetPixelFormatUi(item.TaskOverride, profile);
|
||||
|
||||
string tgtEffPix;
|
||||
var codecForEff = encoderHint?.Codec ?? cmd.VideoEncoderForLog;
|
||||
tgtEffPix = FfmpegCommandBuilder.TryGetEffectiveVideoOutputPixFmt(mergedPxUi, codecForEff, out var effPx)
|
||||
? effPx!
|
||||
: "?";
|
||||
|
||||
var mergedVidRaw = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
|
||||
var tgtVid = FormatMergedTargetVideoForLog(mergedVidRaw);
|
||||
|
||||
_logging.Info($"Video transcode: {srcCodec} / {srcPix} -> {tgtVid} / {tgtEffPix}", "conversion.exec");
|
||||
if (cmd.AppliedVideoFilters.Count > 0)
|
||||
{
|
||||
_logging.Info($"Video filter: {string.Join(",", cmd.AppliedVideoFilters)}", "conversion.exec");
|
||||
}
|
||||
|
||||
_logging.Info($"Encoder: {cmd.VideoEncoderForLog}", "conversion.exec");
|
||||
}
|
||||
|
||||
private static string FormatMergedTargetVideoForLog(string mergedVideo)
|
||||
{
|
||||
var v = mergedVideo.Trim();
|
||||
if (string.IsNullOrWhiteSpace(v))
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
if (v.Contains("265", StringComparison.OrdinalIgnoreCase)
|
||||
|| v.Contains("hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "H.265 / HEVC";
|
||||
}
|
||||
|
||||
if (v.Contains("264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "H.264";
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
private static bool CommandArgumentsUseNvencVideo(IReadOnlyList<string> args)
|
||||
{
|
||||
for (var i = 0; i < args.Count - 1; i++)
|
||||
{
|
||||
if (!args[i].StartsWith("-c:v", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (args[i + 1].Contains("nvenc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksLikeNvencPipelineFailure(string stderr) =>
|
||||
!string.IsNullOrWhiteSpace(stderr) &&
|
||||
(stderr.Contains("Impossible to convert between the formats", StringComparison.OrdinalIgnoreCase)
|
||||
|| stderr.Contains("auto_scale_", StringComparison.OrdinalIgnoreCase)
|
||||
|| stderr.Contains("Error reinitializing filters", StringComparison.OrdinalIgnoreCase)
|
||||
|| stderr.Contains("Function not implemented", StringComparison.OrdinalIgnoreCase)
|
||||
|| (stderr.Contains("nvenc", StringComparison.OrdinalIgnoreCase)
|
||||
&& stderr.Contains("Could not open encoder", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
/// <summary>
|
||||
/// ffprobe может вернуть yuvj420p (JPEG full-range) там, где в профиле задано yuv420p (limited);
|
||||
/// по сути тот же 8-bit 4:2:0 планарный — для Emby/совместимости считаем допустимым совпадением.
|
||||
/// </summary>
|
||||
private static bool PixelFormatsMatchForValidation(string expectedNorm, string actualNorm)
|
||||
{
|
||||
if (string.Equals(expectedNorm, actualNorm, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool IsYuv420pFamily(string p) =>
|
||||
p.Equals("yuv420p", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Equals("yuvj420p", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return IsYuv420pFamily(expectedNorm) && IsYuv420pFamily(actualNorm);
|
||||
}
|
||||
}
|
||||
|
||||
848
EmbyToolbox/Services/ConversionPlanService.cs
Normal file
848
EmbyToolbox/Services/ConversionPlanService.cs
Normal file
@ -0,0 +1,848 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Строит план сравнения с профилем: без вызова ffmpeg.</summary>
|
||||
public sealed class ConversionPlanService
|
||||
{
|
||||
/// <summary>Встроенная аудиодорожка уже соответствует целевому аудиокодеку профиля (AAC и т.д.).</summary>
|
||||
public static bool EmbeddedAudioMatchesProfile(string? fileCodec, ConversionProfileSettingsEntry profile) =>
|
||||
AudioCodecMatchesAgainstLabel(fileCodec, profile.Audio);
|
||||
|
||||
/// <summary>Сравнение кодека файла с подписью цели (как в snapshot), без полного профиля.</summary>
|
||||
public static bool VideoCodecMatchesTarget(string? fileCodec, string targetVideoLabel) =>
|
||||
VideoCodecMatches(fileCodec, targetVideoLabel);
|
||||
|
||||
public static bool AudioCodecMatchesTarget(string? fileCodec, string targetAudioLabel) =>
|
||||
AudioCodecMatchesAgainstLabel(fileCodec, targetAudioLabel);
|
||||
|
||||
public ConversionPlan Build(
|
||||
MediaAnalysisResult media,
|
||||
IReadOnlyList<SidecarFile> sidecars,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
ConversionTaskOverride? ovr,
|
||||
IReadOnlyList<ExternalAudioFile>? externalAudioForBaseline = null)
|
||||
{
|
||||
var steps = new List<string>();
|
||||
var p = MergeProfile(profile, ovr);
|
||||
var v = media.PrimaryVideo;
|
||||
var cont = (media.ContainerFormat ?? string.Empty).ToLowerInvariant();
|
||||
var requiresTimestampFix = MpegTsTimestampHelpers.IsMpegTsInput(media, null) && WantsMkv(p) && !IsMkvContainer(cont);
|
||||
|
||||
if (WantsMkv(p) && !IsMkvContainer(cont))
|
||||
{
|
||||
steps.Add("Remux to MKV");
|
||||
}
|
||||
|
||||
if (requiresTimestampFix)
|
||||
{
|
||||
steps.Add("MPEG-TS: исправление меток времени (genpts / mux; при сбое — перекодирование видео)");
|
||||
}
|
||||
else if (WantsMp4(p) && !IsMp4ish(cont) && !HasStep(steps, "Remux"))
|
||||
{
|
||||
steps.Add("Remux to MP4");
|
||||
}
|
||||
|
||||
if (v is not null)
|
||||
{
|
||||
if (!VideoCodecMatches(v.CodecName, p.Video))
|
||||
{
|
||||
steps.Add("Convert video to " + p.Video);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(p.PixelFormat) && !IsUnchanged(p.PixelFormat) &&
|
||||
!StringEqualsLoose(v.PixelFormat, ToPixNorm(p.PixelFormat)))
|
||||
{
|
||||
steps.Add("Convert pixel format to " + p.PixelFormat);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(p.Resolution) && !IsUnchanged(p.Resolution))
|
||||
{
|
||||
if (v.Width is { } w && v.Height is { } h)
|
||||
{
|
||||
if (!ResMatchesFile(w, h, p.Resolution))
|
||||
{
|
||||
steps.Add("Resolution: " + p.Resolution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(p.Fps) && !IsUnchanged(p.Fps) && v.FrameRate is { } f)
|
||||
{
|
||||
if (TryParseMaxValue(p.Fps) is { } fpsMax)
|
||||
{
|
||||
if (f > fpsMax + FpsEpsilon)
|
||||
{
|
||||
steps.Add($"FPS: максимум {FormatNumber(fpsMax)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var targetVideoBitrateKbps = VideoBitratePolicy.ResolveTargetKbps(
|
||||
p.VideoBitrateMode,
|
||||
p.VideoBitrateMbps,
|
||||
media);
|
||||
var normalizedVideoBitrateMode = VideoBitratePolicy.NormalizeMode(p.VideoBitrateMode);
|
||||
var videoWillTranscode = RequiresVideoTranscode(media, profile, ovr);
|
||||
if (videoWillTranscode)
|
||||
{
|
||||
var bitrateStep = BuildVideoBitrateStep(normalizedVideoBitrateMode, targetVideoBitrateKbps);
|
||||
if (!string.IsNullOrWhiteSpace(bitrateStep))
|
||||
{
|
||||
steps.Add(bitrateStep);
|
||||
}
|
||||
}
|
||||
|
||||
var hasOverrideList = ovr is { TrackOverrides.Count: > 0 };
|
||||
if (hasOverrideList && ovr is not null)
|
||||
{
|
||||
AppendSubtitlePlanSteps(media, ovr, p, steps);
|
||||
}
|
||||
|
||||
if (hasOverrideList && ovr!.TrackOverrides.Any(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Audio, Action: TrackActionKind.Convert }))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(p.Audio) && !IsUnchanged(p.Audio))
|
||||
{
|
||||
steps.Add("Convert audio to " + p.Audio + " " + p.Bitrate);
|
||||
}
|
||||
}
|
||||
else if (!hasOverrideList)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(p.Audio) && !IsUnchanged(p.Audio))
|
||||
{
|
||||
var want = p.Audio;
|
||||
if (media.AudioStreams.Any() && !media.AudioStreams.All(a => AudioCodecMatchesAgainstLabel(a.CodecName, p.Audio)))
|
||||
{
|
||||
steps.Add("Convert audio to " + p.Audio + " " + p.Bitrate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (p.Subtitles is "Нет" or "No" or "false")
|
||||
{
|
||||
if (media.SubtitleStreams.Count > 0)
|
||||
{
|
||||
steps.Add("Remove non-RUS subtitles");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ProfileRemovesNonRusSubs(p) && media.SubtitleStreams.Count > 0)
|
||||
{
|
||||
steps.Add("Remove non-RUS subtitles");
|
||||
}
|
||||
}
|
||||
|
||||
if (p.ExternalSubtitles is "Да" or "Yes" or "true")
|
||||
{
|
||||
if (sidecars.Any(s => s.IsSubtitle))
|
||||
{
|
||||
steps.Add("Add external subtitles rus default");
|
||||
}
|
||||
}
|
||||
|
||||
if (p.ExternalTracks is "Да" or "Yes" or "true")
|
||||
{
|
||||
var externalAudioAdds = ovr?.TrackOverrides
|
||||
.Where(t => t is
|
||||
{
|
||||
Source: SourceKind.External,
|
||||
StreamKind: MediaStreamKind.Audio,
|
||||
Action: TrackActionKind.Add
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (externalAudioAdds is { Count: > 0 })
|
||||
{
|
||||
if (externalAudioAdds.All(t => IsAacCodec(t.ExternalStreamCodec)))
|
||||
{
|
||||
steps.Add("Add external audio AAC copy");
|
||||
}
|
||||
else
|
||||
{
|
||||
steps.Add("Add external audio -> AAC 256 kbps");
|
||||
}
|
||||
}
|
||||
else if (sidecars.Any(s => s.IsAudio))
|
||||
{
|
||||
steps.Add("Add external audio rus default");
|
||||
}
|
||||
}
|
||||
|
||||
var plannedFontAdds = ovr?.TrackOverrides.Count(
|
||||
t => t is
|
||||
{
|
||||
Source: SourceKind.External,
|
||||
StreamKind: MediaStreamKind.Attachment,
|
||||
Action: TrackActionKind.Add
|
||||
}) ?? 0;
|
||||
if (plannedFontAdds > 0)
|
||||
{
|
||||
if (SupportsAttachments(p.Container))
|
||||
{
|
||||
steps.Add("Add external fonts attachments");
|
||||
}
|
||||
else
|
||||
{
|
||||
steps.Add("Fonts skipped: container does not support attachments");
|
||||
}
|
||||
}
|
||||
|
||||
if (media.DataStreams.Count > 0)
|
||||
{
|
||||
steps.Add("Remove data streams");
|
||||
}
|
||||
|
||||
var stats = ConversionPlanActionStats.FromOverrides(ovr);
|
||||
var hasRealActions = HasRealActions(steps, stats, media, sidecars, profile, ovr, externalAudioForBaseline);
|
||||
var shortSummary = BuildCountSummary(p, media, steps, stats, ovr, plannedFontAdds, hasRealActions);
|
||||
var trackParts = BuildTrackParts(ovr, p.Container, media);
|
||||
|
||||
if (!hasRealActions)
|
||||
{
|
||||
return new ConversionPlan
|
||||
{
|
||||
SuggestsSkip = true,
|
||||
HasRealActions = false,
|
||||
StepDescriptions = new[] { "Skip — обработка не требуется" },
|
||||
ActionStats = stats,
|
||||
TrackParts = trackParts,
|
||||
ShortSummary = "Skip — обработка не требуется",
|
||||
TargetVideoBitrateMode = normalizedVideoBitrateMode,
|
||||
TargetVideoBitrateKbps = targetVideoBitrateKbps,
|
||||
RequiresTimestampFix = requiresTimestampFix
|
||||
};
|
||||
}
|
||||
|
||||
return new ConversionPlan
|
||||
{
|
||||
SuggestsSkip = false,
|
||||
HasRealActions = true,
|
||||
StepDescriptions = steps,
|
||||
ActionStats = stats,
|
||||
TrackParts = trackParts,
|
||||
ShortSummary = shortSummary,
|
||||
TargetVideoBitrateMode = normalizedVideoBitrateMode,
|
||||
TargetVideoBitrateKbps = targetVideoBitrateKbps,
|
||||
RequiresTimestampFix = requiresTimestampFix
|
||||
};
|
||||
}
|
||||
|
||||
public static bool RequiresVideoTranscode(
|
||||
MediaAnalysisResult media,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
ConversionTaskOverride? ovr)
|
||||
{
|
||||
var v = media.PrimaryVideo;
|
||||
if (v is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var p = MergeProfile(profile, ovr);
|
||||
if (!VideoCodecMatches(v.CodecName, p.Video))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(p.PixelFormat) && !IsUnchanged(p.PixelFormat) &&
|
||||
!StringEqualsLoose(v.PixelFormat, ToPixNorm(p.PixelFormat)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(p.Resolution) && !IsUnchanged(p.Resolution))
|
||||
{
|
||||
if (v.Width is { } w && v.Height is { } h && !ResMatchesFile(w, h, p.Resolution))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(p.Fps) && !IsUnchanged(p.Fps) && v.FrameRate is { } f)
|
||||
{
|
||||
if (TryParseMaxValue(p.Fps) is { } fpsMax && f > fpsMax + FpsEpsilon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedVideoBitrateMode = VideoBitratePolicy.NormalizeMode(p.VideoBitrateMode);
|
||||
if (normalizedVideoBitrateMode == VideoBitratePolicy.Auto)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedVideoBitrateMode == VideoBitratePolicy.Source && media.SourceVideoBitrateBps is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetVideoLabel = p.Video ?? string.Empty;
|
||||
if (targetVideoLabel.Contains("copy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasStep(IReadOnlyList<string> s, string sub) => s.Any(x => x.Contains(sub, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string BuildCountSummary(
|
||||
ConversionProfileSettingsEntry p,
|
||||
MediaAnalysisResult m,
|
||||
IReadOnlyList<string> steps,
|
||||
ConversionPlanActionStats stats,
|
||||
ConversionTaskOverride? ovr,
|
||||
int plannedFontAdds,
|
||||
bool hasRealActions)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var step in steps.Where(s => !string.IsNullOrWhiteSpace(s)))
|
||||
{
|
||||
parts.Add(step);
|
||||
}
|
||||
|
||||
var addSub = 0;
|
||||
var addAudio = 0;
|
||||
var addFonts = 0;
|
||||
var removeAudio = 0;
|
||||
var removeSubtitle = 0;
|
||||
var removeOther = 0;
|
||||
if (ovr is { TrackOverrides.Count: > 0 })
|
||||
{
|
||||
addSub = ovr.TrackOverrides.Count(
|
||||
t => t is
|
||||
{
|
||||
Source: SourceKind.External,
|
||||
StreamKind: MediaStreamKind.Subtitle,
|
||||
Action: TrackActionKind.Add
|
||||
});
|
||||
addAudio = ovr.TrackOverrides.Count(
|
||||
t => t is
|
||||
{
|
||||
Source: SourceKind.External,
|
||||
StreamKind: MediaStreamKind.Audio,
|
||||
Action: TrackActionKind.Add
|
||||
});
|
||||
addFonts = ovr.TrackOverrides.Count(
|
||||
t => t is
|
||||
{
|
||||
Source: SourceKind.External,
|
||||
StreamKind: MediaStreamKind.Attachment,
|
||||
Action: TrackActionKind.Add
|
||||
});
|
||||
removeAudio = ovr.TrackOverrides.Count(
|
||||
t => t is
|
||||
{
|
||||
Source: SourceKind.Embedded,
|
||||
StreamKind: MediaStreamKind.Audio,
|
||||
Action: TrackActionKind.Remove
|
||||
});
|
||||
removeSubtitle = ovr.TrackOverrides.Count(
|
||||
t => t is
|
||||
{
|
||||
Source: SourceKind.Embedded,
|
||||
StreamKind: MediaStreamKind.Subtitle,
|
||||
Action: TrackActionKind.Remove
|
||||
});
|
||||
removeOther = ovr.TrackOverrides.Count(
|
||||
t => t is { Source: SourceKind.Embedded, Action: TrackActionKind.Remove }
|
||||
&& t.StreamKind is not MediaStreamKind.Audio
|
||||
&& t.StreamKind is not MediaStreamKind.Subtitle);
|
||||
}
|
||||
|
||||
if (addSub > 0)
|
||||
{
|
||||
parts.Add("Add external subtitle: " + addSub);
|
||||
}
|
||||
|
||||
if (addAudio > 0)
|
||||
{
|
||||
parts.Add("Add external audio: " + addAudio);
|
||||
}
|
||||
|
||||
if (plannedFontAdds > 0)
|
||||
{
|
||||
if (SupportsAttachments(p.Container))
|
||||
{
|
||||
parts.Add("Add fonts: " + addFonts);
|
||||
}
|
||||
}
|
||||
|
||||
if (removeSubtitle > 0)
|
||||
{
|
||||
parts.Add("Remove subtitle: " + removeSubtitle);
|
||||
}
|
||||
|
||||
if (removeAudio > 0)
|
||||
{
|
||||
parts.Add("Remove audio: " + removeAudio);
|
||||
}
|
||||
|
||||
if (removeOther > 0)
|
||||
{
|
||||
parts.Add("Remove tracks: " + removeOther);
|
||||
}
|
||||
|
||||
if (stats.ConvertAudio > 0)
|
||||
{
|
||||
parts.Add("Convert audio: " + stats.ConvertAudio);
|
||||
}
|
||||
|
||||
if (!hasRealActions)
|
||||
{
|
||||
return "Skip — обработка не требуется";
|
||||
}
|
||||
|
||||
var summary = string.Join(" | ", parts
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
return string.IsNullOrWhiteSpace(summary) ? "Track metadata changed" : summary;
|
||||
}
|
||||
|
||||
private static bool HasRealActions(
|
||||
IReadOnlyList<string> steps,
|
||||
ConversionPlanActionStats stats,
|
||||
MediaAnalysisResult media,
|
||||
IReadOnlyList<SidecarFile> sidecars,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
ConversionTaskOverride? ovr,
|
||||
IReadOnlyList<ExternalAudioFile>? externalAudioForBaseline)
|
||||
{
|
||||
if (steps.Count > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (stats is { Add: > 0 } or { Remove: > 0 } or { ConvertAudio: > 0 } or { ConvertVideo: > 0 } or { SubtitleConvert: > 0 } or { SubtitleRemove: > 0 })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (HasTrackOverrideChanges(media, sidecars, profile, ovr, externalAudioForBaseline))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasTrackOverrideChanges(
|
||||
MediaAnalysisResult media,
|
||||
IReadOnlyList<SidecarFile> sidecars,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
ConversionTaskOverride? ovr,
|
||||
IReadOnlyList<ExternalAudioFile>? externalAudioForBaseline)
|
||||
{
|
||||
if (ovr is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var baseline = new ConversionTaskOverride();
|
||||
TrackOverrideSeeder.EnsureDefaults(baseline, media, sidecars, profile, externalAudio: externalAudioForBaseline);
|
||||
return !OverridesEquivalent(ovr, baseline);
|
||||
}
|
||||
|
||||
private static bool OverridesEquivalent(ConversionTaskOverride left, ConversionTaskOverride right)
|
||||
{
|
||||
if (!StringEq(left.TargetContainer, right.TargetContainer)
|
||||
|| !StringEq(left.TargetVideo, right.TargetVideo)
|
||||
|| !StringEq(left.TargetPixelFormat, right.TargetPixelFormat)
|
||||
|| !StringEq(left.TargetResolution, right.TargetResolution)
|
||||
|| !StringEq(left.TargetFps, right.TargetFps)
|
||||
|| !StringEq(left.TargetAudioBitrate, right.TargetAudioBitrate)
|
||||
|| !StringEq(left.TargetVideoBitrateMode, right.TargetVideoBitrateMode)
|
||||
|| left.TargetVideoBitrateMbps != right.TargetVideoBitrateMbps)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var l = left.TrackOverrides.OrderBy(TrackKey).ToArray();
|
||||
var r = right.TrackOverrides.OrderBy(TrackKey).ToArray();
|
||||
if (l.Length != r.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < l.Length; i++)
|
||||
{
|
||||
if (!TrackEquivalent(l[i], r[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TrackEquivalent(TrackOverrideEntry a, TrackOverrideEntry b)
|
||||
{
|
||||
return a.StreamIndex == b.StreamIndex
|
||||
&& a.Source == b.Source
|
||||
&& a.StreamKind == b.StreamKind
|
||||
&& a.Action == b.Action
|
||||
&& a.Default == b.Default
|
||||
&& StringEq(a.ExternalPath, b.ExternalPath)
|
||||
&& a.ExternalAudioStreamOrdinal == b.ExternalAudioStreamOrdinal
|
||||
&& StringEq(a.ExternalStreamCodec, b.ExternalStreamCodec)
|
||||
&& StringEq(a.ExternalFfprobeTitle, b.ExternalFfprobeTitle)
|
||||
&& StringEq(a.Language, b.Language)
|
||||
&& StringEq(a.Title, b.Title)
|
||||
&& StringEq(a.AudioBitrateKbps, b.AudioBitrateKbps);
|
||||
}
|
||||
|
||||
private static string TrackKey(TrackOverrideEntry t) =>
|
||||
$"{(int)t.Source}|{(int)t.StreamKind}|{t.StreamIndex}|{(t.ExternalPath ?? string.Empty).Trim()}|a{t.ExternalAudioStreamOrdinal}";
|
||||
|
||||
private static bool StringEq(string? a, string? b) =>
|
||||
string.Equals((a ?? string.Empty).Trim(), (b ?? string.Empty).Trim(), StringComparison.Ordinal);
|
||||
|
||||
private static string? BuildVideoBitrateStep(string normalizedMode, int? targetVideoBitrateKbps)
|
||||
{
|
||||
if (normalizedMode == VideoBitratePolicy.Auto)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalizedMode == VideoBitratePolicy.Source)
|
||||
{
|
||||
return "Video bitrate: source";
|
||||
}
|
||||
|
||||
if (targetVideoBitrateKbps is not { } bitrateKbps || bitrateKbps <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"Video bitrate: {bitrateKbps / 1000.0:0.###} Mbps";
|
||||
}
|
||||
|
||||
private static string DescribeTrackPlanLine(TrackOverrideEntry t, bool supportsAttachments, MediaAnalysisResult? media)
|
||||
{
|
||||
if (t.StreamKind == MediaStreamKind.Attachment && t.Action == TrackActionKind.Add && !supportsAttachments)
|
||||
{
|
||||
return "Fonts skipped: container does not support attachments";
|
||||
}
|
||||
|
||||
if (t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Remove })
|
||||
{
|
||||
var codec = media?.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
|
||||
return SubtitleCodecRules.IsTeletext(codec) ? "Удалить teletext subtitle" : "Remove subtitle: 1";
|
||||
}
|
||||
|
||||
return $"{t.Action} {t.StreamKind}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ConversionTrackPlan> BuildTrackParts(ConversionTaskOverride? ovr, string container, MediaAnalysisResult? media)
|
||||
{
|
||||
if (ovr is null || ovr.TrackOverrides.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var supportsAttachments = SupportsAttachments(container);
|
||||
var list = new List<ConversionTrackPlan>(ovr.TrackOverrides.Count);
|
||||
foreach (var t in ovr.TrackOverrides)
|
||||
{
|
||||
var action = t.Action switch
|
||||
{
|
||||
TrackActionKind.Add => t.StreamKind == MediaStreamKind.Attachment && !supportsAttachments
|
||||
? ConversionPlanAction.None
|
||||
: t.StreamKind == MediaStreamKind.Subtitle
|
||||
? ConversionPlanAction.AddExternalSubtitles
|
||||
: ConversionPlanAction.AddExternalAudio,
|
||||
TrackActionKind.Remove => t.StreamKind == MediaStreamKind.Subtitle
|
||||
? ConversionPlanAction.RemoveNonRusSubtitles
|
||||
: ConversionPlanAction.RemoveDataStreams,
|
||||
TrackActionKind.Convert => t.StreamKind == MediaStreamKind.Audio
|
||||
? ConversionPlanAction.ConvertAudio
|
||||
: t.StreamKind == MediaStreamKind.Video
|
||||
? ConversionPlanAction.ConvertVideo
|
||||
: ConversionPlanAction.None,
|
||||
_ => ConversionPlanAction.None
|
||||
};
|
||||
|
||||
var desc = DescribeTrackPlanLine(t, supportsAttachments, media);
|
||||
|
||||
list.Add(
|
||||
new ConversionTrackPlan
|
||||
{
|
||||
StreamIndex = t.StreamIndex,
|
||||
Source = t.Source,
|
||||
StreamKind = t.StreamKind,
|
||||
Action = action,
|
||||
Description = desc
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool WantsMkv(ConversionProfileSettingsEntry p) =>
|
||||
p.Container.Equals("MKV", StringComparison.OrdinalIgnoreCase) || p.Container.Contains("Matro", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool WantsMp4(ConversionProfileSettingsEntry p) =>
|
||||
p.Container.Equals("MP4", StringComparison.OrdinalIgnoreCase) || p.Container.Equals("M4V", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool SupportsAttachments(string? c) =>
|
||||
!string.IsNullOrWhiteSpace(c) && (c.Contains("mkv", StringComparison.OrdinalIgnoreCase) || c.Contains("matro", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsMkvContainer(string c) => c.Contains("matro", StringComparison.Ordinal) || c.Contains("mkv", StringComparison.Ordinal) || c.Contains("webm", StringComparison.Ordinal);
|
||||
|
||||
private static bool IsMp4ish(string c) => c.Contains("mp4", StringComparison.Ordinal) || c.Contains("mov", StringComparison.Ordinal) || c.Contains("isom", StringComparison.Ordinal);
|
||||
|
||||
private static bool IsUnchanged(string? s) =>
|
||||
string.IsNullOrWhiteSpace(s) || s.Contains("без", StringComparison.OrdinalIgnoreCase) || s.Contains("No change", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string ToPixNorm(string p) => p.Replace(" ", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
private static ConversionProfileSettingsEntry MergeProfile(ConversionProfileSettingsEntry profile, ConversionTaskOverride? ovr)
|
||||
{
|
||||
if (ovr is null)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
static string Coa(string? a, string b) => string.IsNullOrWhiteSpace(a) ? b : a!.Trim();
|
||||
return profile with
|
||||
{
|
||||
Container = Coa(ovr.TargetContainer, profile.Container),
|
||||
Video = Coa(ovr.TargetVideo, profile.Video),
|
||||
PixelFormat = Coa(ovr.TargetPixelFormat, profile.PixelFormat),
|
||||
Resolution = Coa(ovr.TargetResolution, profile.Resolution),
|
||||
Fps = Coa(ovr.TargetFps, profile.Fps),
|
||||
Bitrate = Coa(ovr.TargetAudioBitrate, profile.Bitrate),
|
||||
VideoBitrateMode = Coa(ovr.TargetVideoBitrateMode, profile.VideoBitrateMode),
|
||||
VideoBitrateMbps = ovr.TargetVideoBitrateMbps > 0 ? ovr.TargetVideoBitrateMbps : profile.VideoBitrateMbps
|
||||
};
|
||||
}
|
||||
|
||||
private static bool StringEqualsLoose(string? a, string? b)
|
||||
{
|
||||
if (a is null || b is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Replace(" ", string.Empty, StringComparison.Ordinal).Equals(b.Replace(" ", string.Empty, StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool VideoCodecMatches(string? fileCodec, string profileVideo)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileCodec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (profileVideo.Contains("Copy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var f = fileCodec.ToLowerInvariant();
|
||||
if (IsUnchanged(profileVideo))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (profileVideo.Contains("H.264", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("H264", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("avc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("h264", StringComparison.Ordinal) || f.Contains("avc", StringComparison.Ordinal) || f == "h264" || f == "h.264";
|
||||
}
|
||||
|
||||
if (profileVideo.Contains("H.265", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("h265", StringComparison.Ordinal) || f.Contains("hevc", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return f.Contains(profileVideo, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool AudioCodecMatchesAgainstLabel(string? fileCodec, string profileAudio)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileCodec))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (profileAudio.Contains("Copy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsUnchanged(profileAudio))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var f = fileCodec.ToLowerInvariant();
|
||||
if (profileAudio.Contains("AAC", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("aac", StringComparison.Ordinal) || f.Contains("fdk", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (profileAudio.Contains("AC-3", StringComparison.Ordinal) || profileAudio.Contains("AC3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("ac3", StringComparison.Ordinal) || f == "eac3";
|
||||
}
|
||||
|
||||
if (profileAudio.Contains("EAC3", StringComparison.OrdinalIgnoreCase) || profileAudio.Contains("E-AC-3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("eac3", StringComparison.Ordinal) || f.Contains("ac3", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (profileAudio.Contains("Opus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("opus", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (profileAudio.Contains("MP3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("mp3", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (profileAudio.Contains("FLAC", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return f.Contains("flac", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return f.Contains(profileAudio, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ResMatchesFile(int w, int h, string pRes)
|
||||
{
|
||||
if (TryParseMaxValue(pRes) is { } maxRes)
|
||||
{
|
||||
// "Максимум 1080p": ограничиваем вертикальное измерение (короткая сторона кадра).
|
||||
var sourceVertical = System.Math.Min(w, h);
|
||||
return sourceVertical <= maxRes;
|
||||
}
|
||||
|
||||
if (pRes.Contains("1080", StringComparison.Ordinal))
|
||||
{
|
||||
return w == 1920 && h == 1080;
|
||||
}
|
||||
|
||||
if (pRes.Contains("720", StringComparison.Ordinal))
|
||||
{
|
||||
return w == 1280 && h == 720;
|
||||
}
|
||||
|
||||
if (pRes.Contains("2160", StringComparison.Ordinal) || pRes.Contains("4K", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return w == 3840 && h == 2160;
|
||||
}
|
||||
|
||||
var m = pRes.Split('x', 'X');
|
||||
if (m.Length == 2 && int.TryParse(m[0], out var pw) && int.TryParse(m[1], out var ph))
|
||||
{
|
||||
return w == pw && h == ph;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double? TryParseMaxValue(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (double.TryParse(s.Replace(',', '.').Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
return d;
|
||||
}
|
||||
|
||||
var normalized = s.Trim();
|
||||
var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
for (var i = parts.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var token = parts[i]
|
||||
.Replace("p", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("fps", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Trim();
|
||||
if (double.TryParse(token.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out d))
|
||||
{
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatNumber(double value) =>
|
||||
value % 1d == 0d
|
||||
? ((int)value).ToString(CultureInfo.InvariantCulture)
|
||||
: value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
private const double FpsEpsilon = 0.01;
|
||||
|
||||
private static void AppendSubtitlePlanSteps(MediaAnalysisResult media, ConversionTaskOverride ovr, ConversionProfileSettingsEntry profile, List<string> steps)
|
||||
{
|
||||
var effective = string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer;
|
||||
var removed = ovr.TrackOverrides
|
||||
.Where(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Remove })
|
||||
.ToList();
|
||||
if (removed.Count > 0)
|
||||
{
|
||||
var tel = 0;
|
||||
var other = 0;
|
||||
foreach (var t in removed)
|
||||
{
|
||||
var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
|
||||
if (SubtitleCodecRules.IsTeletext(codec))
|
||||
{
|
||||
tel++;
|
||||
}
|
||||
else
|
||||
{
|
||||
other++;
|
||||
}
|
||||
}
|
||||
|
||||
if (tel > 0)
|
||||
{
|
||||
steps.Add("Удалить teletext subtitle");
|
||||
}
|
||||
|
||||
if (other > 0)
|
||||
{
|
||||
steps.Add($"Remove subtitle: {other}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!SubtitleCodecRules.TargetsMp4(effective))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var transcodeSubs = ovr.TrackOverrides
|
||||
.Where(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle }
|
||||
&& t.Action is TrackActionKind.Convert or TrackActionKind.Keep)
|
||||
.Where(t =>
|
||||
{
|
||||
var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
|
||||
return SubtitleCodecRules.Mp4RequiresSubtitleTranscode(codec);
|
||||
}).ToList();
|
||||
if (transcodeSubs.Count > 0)
|
||||
{
|
||||
steps.Add("Subtitle -> mov_text (MP4)");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ProfileRemovesNonRusSubs(ConversionProfileSettingsEntry p) =>
|
||||
p.Subtitles is "только" or "RUS" or "rus" || p.Profile.Contains("rus", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsAacCodec(string? codecName) =>
|
||||
!string.IsNullOrWhiteSpace(codecName)
|
||||
&& codecName.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
24
EmbyToolbox/Services/ConversionProfileMapping.cs
Normal file
24
EmbyToolbox/Services/ConversionProfileMapping.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public static class ConversionProfileMapping
|
||||
{
|
||||
/// <summary>Запасной профиль, если имя в очереди не найдено в списке.</summary>
|
||||
public static ConversionProfileSettingsEntry EmbyFallback { get; } = new()
|
||||
{
|
||||
Profile = "Emby",
|
||||
Container = "MKV",
|
||||
Video = "H.264",
|
||||
PixelFormat = "yuv420p",
|
||||
Resolution = "Без изменений",
|
||||
Fps = "Без изменений",
|
||||
Audio = "AAC",
|
||||
Bitrate = "256 kbps",
|
||||
VideoBitrateMode = VideoBitratePolicy.Auto,
|
||||
Subtitles = "Да",
|
||||
ExternalTracks = "Да",
|
||||
ExternalSubtitles = "Да",
|
||||
Fonts = "Да"
|
||||
};
|
||||
}
|
||||
281
EmbyToolbox/Services/ConversionQueueSetupPersistence.cs
Normal file
281
EmbyToolbox/Services/ConversionQueueSetupPersistence.cs
Normal file
@ -0,0 +1,281 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Сохранение и загрузка очереди конвертации (.conv_setup, JSON).</summary>
|
||||
public static class ConversionQueueSetupPersistence
|
||||
{
|
||||
public const string FileExtension = ".conv_setup";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public static string GetQueueSetupsDirectory()
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"EmbyToolbox",
|
||||
"QueueSetups");
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public static string AllocateAutoSavePath()
|
||||
{
|
||||
var stamp = DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
return Path.Combine(GetQueueSetupsDirectory(), $"conversion-setup-{stamp}{FileExtension}");
|
||||
}
|
||||
|
||||
public static void SaveToPath(string path, ConversionQueueSetupRoot document)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(document, JsonOptions);
|
||||
File.WriteAllText(path, json, System.Text.UTF8Encoding.UTF8);
|
||||
}
|
||||
|
||||
public static ConversionQueueSetupRoot LoadFromPath(string path)
|
||||
{
|
||||
var json = File.ReadAllText(path, System.Text.UTF8Encoding.UTF8);
|
||||
var doc = JsonSerializer.Deserialize<ConversionQueueSetupRoot>(json, JsonOptions)
|
||||
?? throw new InvalidDataException("Пустой или некорректный .conv_setup");
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ConversionQueueSetupRoot
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public DateTime SavedAtUtc { get; set; }
|
||||
public string? DefaultQueueProfile { get; set; }
|
||||
public bool CopyPreviousTrackSettings { get; set; }
|
||||
public bool DisableSubtitleDefault { get; set; }
|
||||
public ConversionFormOptionsSnapshot FormOptions { get; set; } = new();
|
||||
public List<ConversionProfileSettingsEntry> Profiles { get; set; } = [];
|
||||
public List<ConversionQueueTaskPersistModel> Tasks { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ConversionFormOptionsSnapshot
|
||||
{
|
||||
public List<string> ContainerOptions { get; set; } = [];
|
||||
public List<string> VideoCodecOptions { get; set; } = [];
|
||||
public List<string> PixelFormatOptions { get; set; } = [];
|
||||
public List<string> ResolutionOptions { get; set; } = [];
|
||||
public List<string> FpsOptions { get; set; } = [];
|
||||
public List<string> AudioBitrateKbps { get; set; } = [];
|
||||
public List<string> VideoBitrateModeOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ConversionQueueTaskPersistModel
|
||||
{
|
||||
public string FullPath { get; set; } = "";
|
||||
public string? SnapshotScopeBatchRoot { get; set; }
|
||||
public int OrderNumber { get; set; }
|
||||
public string Profile { get; set; } = "Emby";
|
||||
public string PlanSummary { get; set; } = "";
|
||||
public string Status { get; set; } = ConversionQueueStatus.Pending;
|
||||
public int Progress { get; set; }
|
||||
public bool IsManuallyEdited { get; set; }
|
||||
public bool IsProcessed { get; set; }
|
||||
public bool ProcessedInCurrentRun { get; set; }
|
||||
public string? LastRunId { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? ErrorDetails { get; set; }
|
||||
public int FileSizeMb { get; set; }
|
||||
public bool HasFfprobeAudioSummary { get; set; }
|
||||
public int FfprobeAudioCount { get; set; }
|
||||
public int? FfprobeAudioSizeMb { get; set; }
|
||||
public bool FfprobeAudioSizePartial { get; set; }
|
||||
public MediaAnalysisResult? MediaAnalysis { get; set; }
|
||||
public List<SidecarFilePersistModel>? Sidecars { get; set; }
|
||||
public List<ExternalAudioFilePersistModel>? ExternalAudioFiles { get; set; }
|
||||
public ConversionTaskOverridePersistModel? Overrides { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SidecarFilePersistModel
|
||||
{
|
||||
public string FullPath { get; set; } = "";
|
||||
public bool IsAudio { get; set; }
|
||||
public bool IsSubtitle { get; set; }
|
||||
public bool IsFont { get; set; }
|
||||
|
||||
public static SidecarFilePersistModel From(SidecarFile s) =>
|
||||
new()
|
||||
{
|
||||
FullPath = s.FullPath,
|
||||
IsAudio = s.IsAudio,
|
||||
IsSubtitle = s.IsSubtitle,
|
||||
IsFont = s.IsFont
|
||||
};
|
||||
|
||||
public SidecarFile ToModel() => new(FullPath, IsAudio, IsSubtitle, IsFont);
|
||||
}
|
||||
|
||||
public sealed class ExternalAudioStreamPersistModel
|
||||
{
|
||||
public string FileFullPath { get; set; } = "";
|
||||
public int StreamOrdinal { get; set; }
|
||||
public string CodecName { get; set; } = "?";
|
||||
public string? TitleFromProbe { get; set; }
|
||||
public int? Channels { get; set; }
|
||||
public int? SampleRateHz { get; set; }
|
||||
public long? BitRateBps { get; set; }
|
||||
|
||||
public static ExternalAudioStreamPersistModel From(ExternalAudioStream s) =>
|
||||
new()
|
||||
{
|
||||
FileFullPath = s.FileFullPath,
|
||||
StreamOrdinal = s.StreamOrdinal,
|
||||
CodecName = s.CodecName,
|
||||
TitleFromProbe = s.TitleFromProbe,
|
||||
Channels = s.Channels,
|
||||
SampleRateHz = s.SampleRateHz,
|
||||
BitRateBps = s.BitRateBps
|
||||
};
|
||||
|
||||
public ExternalAudioStream ToModel() =>
|
||||
new()
|
||||
{
|
||||
FileFullPath = FileFullPath,
|
||||
StreamOrdinal = StreamOrdinal,
|
||||
CodecName = CodecName,
|
||||
TitleFromProbe = TitleFromProbe,
|
||||
Channels = Channels,
|
||||
SampleRateHz = SampleRateHz,
|
||||
BitRateBps = BitRateBps
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class ExternalAudioFilePersistModel
|
||||
{
|
||||
public string FullPath { get; set; } = "";
|
||||
public List<ExternalAudioStreamPersistModel> Streams { get; set; } = [];
|
||||
|
||||
public static ExternalAudioFilePersistModel From(ExternalAudioFile f) =>
|
||||
new()
|
||||
{
|
||||
FullPath = f.FullPath,
|
||||
Streams = f.Streams.Select(ExternalAudioStreamPersistModel.From).ToList()
|
||||
};
|
||||
|
||||
public ExternalAudioFile ToModel() =>
|
||||
new(FullPath, Streams.Select(s => s.ToModel()).ToList());
|
||||
}
|
||||
|
||||
public sealed class ConversionTaskOverridePersistModel
|
||||
{
|
||||
public string TargetContainer { get; set; } = "";
|
||||
public string TargetVideo { get; set; } = "";
|
||||
public string TargetPixelFormat { get; set; } = "";
|
||||
public string TargetResolution { get; set; } = "";
|
||||
public string TargetFps { get; set; } = "";
|
||||
public string TargetAudioBitrate { get; set; } = "256 kbps";
|
||||
public string TargetVideoBitrateMode { get; set; } = VideoBitratePolicy.Auto;
|
||||
public double? TargetVideoBitrateMbps { get; set; }
|
||||
public List<TrackOverrideEntryPersistModel> TrackOverrides { get; set; } = [];
|
||||
|
||||
public static ConversionTaskOverridePersistModel From(ConversionTaskOverride o)
|
||||
{
|
||||
var m = new ConversionTaskOverridePersistModel
|
||||
{
|
||||
TargetContainer = o.TargetContainer,
|
||||
TargetVideo = o.TargetVideo,
|
||||
TargetPixelFormat = o.TargetPixelFormat,
|
||||
TargetResolution = o.TargetResolution,
|
||||
TargetFps = o.TargetFps,
|
||||
TargetAudioBitrate = o.TargetAudioBitrate,
|
||||
TargetVideoBitrateMode = o.TargetVideoBitrateMode,
|
||||
TargetVideoBitrateMbps = o.TargetVideoBitrateMbps
|
||||
};
|
||||
foreach (var t in o.TrackOverrides)
|
||||
{
|
||||
m.TrackOverrides.Add(TrackOverrideEntryPersistModel.From(t));
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
public void ApplyTo(ConversionTaskOverride target)
|
||||
{
|
||||
target.TargetContainer = TargetContainer;
|
||||
target.TargetVideo = TargetVideo;
|
||||
target.TargetPixelFormat = TargetPixelFormat;
|
||||
target.TargetResolution = TargetResolution;
|
||||
target.TargetFps = TargetFps;
|
||||
target.TargetAudioBitrate = TargetAudioBitrate;
|
||||
target.TargetVideoBitrateMode = TargetVideoBitrateMode;
|
||||
target.TargetVideoBitrateMbps = TargetVideoBitrateMbps;
|
||||
target.TrackOverrides.Clear();
|
||||
foreach (var t in TrackOverrides)
|
||||
{
|
||||
target.TrackOverrides.Add(t.ToEntry());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TrackOverrideEntryPersistModel
|
||||
{
|
||||
public int StreamIndex { get; set; }
|
||||
public string? ExternalPath { get; set; }
|
||||
public SourceKind Source { get; set; }
|
||||
public MediaStreamKind StreamKind { get; set; }
|
||||
public TrackActionKind Action { get; set; }
|
||||
public bool? Default { get; set; }
|
||||
public string? Language { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? AudioBitrateKbps { get; set; }
|
||||
public int ExternalAudioStreamOrdinal { get; set; }
|
||||
public string? ExternalStreamCodec { get; set; }
|
||||
public string? ExternalStreamDetails { get; set; }
|
||||
public int SameFileExternalAudioStreamCount { get; set; } = 1;
|
||||
public string? ExternalFfprobeTitle { get; set; }
|
||||
|
||||
public static TrackOverrideEntryPersistModel From(TrackOverrideEntry t) =>
|
||||
new()
|
||||
{
|
||||
StreamIndex = t.StreamIndex,
|
||||
ExternalPath = t.ExternalPath,
|
||||
Source = t.Source,
|
||||
StreamKind = t.StreamKind,
|
||||
Action = t.Action,
|
||||
Default = t.Default,
|
||||
Language = t.Language,
|
||||
Title = t.Title,
|
||||
AudioBitrateKbps = t.AudioBitrateKbps,
|
||||
ExternalAudioStreamOrdinal = t.ExternalAudioStreamOrdinal,
|
||||
ExternalStreamCodec = t.ExternalStreamCodec,
|
||||
ExternalStreamDetails = t.ExternalStreamDetails,
|
||||
SameFileExternalAudioStreamCount = t.SameFileExternalAudioStreamCount,
|
||||
ExternalFfprobeTitle = t.ExternalFfprobeTitle
|
||||
};
|
||||
|
||||
public TrackOverrideEntry ToEntry() =>
|
||||
new()
|
||||
{
|
||||
StreamIndex = StreamIndex,
|
||||
ExternalPath = ExternalPath,
|
||||
Source = Source,
|
||||
StreamKind = StreamKind,
|
||||
Action = Action,
|
||||
Default = Default,
|
||||
Language = Language,
|
||||
Title = Title,
|
||||
AudioBitrateKbps = AudioBitrateKbps,
|
||||
ExternalAudioStreamOrdinal = ExternalAudioStreamOrdinal,
|
||||
ExternalStreamCodec = ExternalStreamCodec,
|
||||
ExternalStreamDetails = ExternalStreamDetails,
|
||||
SameFileExternalAudioStreamCount = SameFileExternalAudioStreamCount,
|
||||
ExternalFfprobeTitle = ExternalFfprobeTitle
|
||||
};
|
||||
}
|
||||
65
EmbyToolbox/Services/ExternalFileCleanupService.cs
Normal file
65
EmbyToolbox/Services/ExternalFileCleanupService.cs
Normal file
@ -0,0 +1,65 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class ExternalFileCleanupService
|
||||
{
|
||||
public IReadOnlyList<string> MoveUsedToUseless(string sourceVideoPath, IReadOnlyList<SidecarFile> usedFiles)
|
||||
{
|
||||
if (usedFiles.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Шрифты считаются переиспользуемыми ресурсами каталога и не перемещаются.
|
||||
var movable = usedFiles
|
||||
.Where(f => !f.IsFont)
|
||||
.DistinctBy(x => x.FullPath)
|
||||
.ToList();
|
||||
if (movable.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(sourceVideoPath);
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var useless = Path.Combine(dir, "useless");
|
||||
Directory.CreateDirectory(useless);
|
||||
var moved = new List<string>();
|
||||
foreach (var f in movable)
|
||||
{
|
||||
if (!File.Exists(f.FullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = BuildUniqueTarget(useless, Path.GetFileName(f.FullPath));
|
||||
File.Move(f.FullPath, target, overwrite: false);
|
||||
moved.Add(target);
|
||||
}
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
private static string BuildUniqueTarget(string dir, string fileName)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(fileName);
|
||||
var ext = Path.GetExtension(fileName);
|
||||
var candidate = Path.Combine(dir, fileName);
|
||||
var i = 1;
|
||||
while (File.Exists(candidate))
|
||||
{
|
||||
candidate = Path.Combine(dir, $"{name}_{i}{ext}");
|
||||
i++;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
172
EmbyToolbox/Services/ExtractCommandBuilder.cs
Normal file
172
EmbyToolbox/Services/ExtractCommandBuilder.cs
Normal file
@ -0,0 +1,172 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Имена выходных файлов и аргументы ffmpeg для извлечения аудио/субтитров/вложений (stream copy).</summary>
|
||||
public sealed class ExtractCommandBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Безопасный фрагмент имени без расширения для префикса выходных файлов (<c>Movie</c> из <c>Movie.mkv</c>).
|
||||
/// </summary>
|
||||
public static string SanitizeSourceFileStem(string? fileNameWithoutExtension)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileNameWithoutExtension))
|
||||
{
|
||||
return "media";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(fileNameWithoutExtension.Trim().Length);
|
||||
foreach (var ch in fileNameWithoutExtension.Trim())
|
||||
{
|
||||
sb.Append(ch is < '\u0020' or '"' or '*' or ':' or '<' or '>' or '?' or '\\' or '/' or '|' ? '_' : ch);
|
||||
}
|
||||
|
||||
var s = sb.ToString().Trim('.', ' ');
|
||||
return string.IsNullOrEmpty(s) ? "media" : s[..Math.Min(s.Length, 200)];
|
||||
}
|
||||
|
||||
/// <summary>Базовое имя файла (без пути): все результаты в общих каталогах, префикс — имя исходника без расширения.</summary>
|
||||
public string ResolveOutputBaseFileName(string sourceStemSanitized, MediaStreamInfo stream, int ordinalInKind)
|
||||
{
|
||||
return stream.Kind switch
|
||||
{
|
||||
MediaStreamKind.Audio =>
|
||||
$"{sourceStemSanitized}_audio_{ordinalInKind:D2}_{SanitizeLangSegment(stream.Language)}.{GetAudioExtension(stream.CodecName)}",
|
||||
MediaStreamKind.Subtitle =>
|
||||
$"{sourceStemSanitized}_subtitle_{ordinalInKind:D2}_{SanitizeLangSegment(stream.Language)}.{GetSubtitleExtension(stream.CodecName)}",
|
||||
MediaStreamKind.Attachment =>
|
||||
$"{sourceStemSanitized}_attachment_{ordinalInKind:D2}_{ResolveAttachmentLeafFileName(stream)}",
|
||||
MediaStreamKind.Video or MediaStreamKind.Data =>
|
||||
throw new InvalidOperationException("Видео и data-потоки не извлекаются."),
|
||||
_ =>
|
||||
throw new InvalidOperationException($"Неподдерживаемый тип потока: {stream.Kind}"),
|
||||
};
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> BuildFfmpegArgumentList(string inputPath, MediaStreamInfo stream, string destinationFullPath)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-map",
|
||||
$"0:{stream.Index}",
|
||||
"-c",
|
||||
"copy",
|
||||
destinationFullPath,
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetAudioExtension(string codecRaw)
|
||||
{
|
||||
var c = NormalizeCodec(codecRaw);
|
||||
return c switch
|
||||
{
|
||||
"aac" => "aac",
|
||||
"ac3" => "ac3",
|
||||
"dts" => "dts",
|
||||
"flac" => "flac",
|
||||
"truehd" => "thd",
|
||||
"eac3" => "eac3",
|
||||
"opus" => "opus",
|
||||
"mp3" => "mp3",
|
||||
"vorbis" => "ogg",
|
||||
_ => "bin",
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSubtitleExtension(string codecRaw)
|
||||
{
|
||||
var c = NormalizeCodec(codecRaw);
|
||||
return c switch
|
||||
{
|
||||
"subrip" or "srt" => "srt",
|
||||
"ass" or "ssa" => "ass",
|
||||
"mov_text" or "text" or "tx3g" => "txt",
|
||||
"hdmv_pgs_subtitle" or "pgssub" => "sup",
|
||||
"dvd_subtitle" or "vobsub" => "sub",
|
||||
_ => "bin",
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveAttachmentLeafFileName(MediaStreamInfo stream)
|
||||
{
|
||||
var declared = stream.AttachmentDeclaredFileName?.Trim();
|
||||
if (string.IsNullOrEmpty(declared))
|
||||
{
|
||||
declared = $"blob_{stream.Index}";
|
||||
}
|
||||
|
||||
declared = SanitizeDeclaredAttachmentFileName(declared);
|
||||
if (!Path.HasExtension(declared))
|
||||
{
|
||||
var mimeExt = MimeToExtension(stream.AttachmentDeclaredMimeType);
|
||||
if (!string.IsNullOrEmpty(mimeExt))
|
||||
{
|
||||
declared += mimeExt.StartsWith('.') ? mimeExt : "." + mimeExt;
|
||||
}
|
||||
}
|
||||
|
||||
return declared;
|
||||
}
|
||||
|
||||
private static string? MimeToExtension(string? mimeRaw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mimeRaw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var m = mimeRaw.Trim().ToLowerInvariant();
|
||||
return m switch
|
||||
{
|
||||
"font/ttf" or "application/x-truetype-font" => ".ttf",
|
||||
"font/otf" or "application/font-sfnt" => ".otf",
|
||||
"application/vnd.ms-opentype" => ".otf",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string SanitizeDeclaredAttachmentFileName(string declared)
|
||||
{
|
||||
var cleaned = declared.Replace('\\', '_').Replace('/', '_');
|
||||
cleaned = Path.GetFileName(cleaned);
|
||||
var sb = new StringBuilder(cleaned.Length);
|
||||
foreach (var ch in cleaned)
|
||||
{
|
||||
sb.Append(ch is < '\u0020' or '"' or '*' or ':' or '<' or '>' or '?' or '|' ? '_' : ch);
|
||||
}
|
||||
|
||||
cleaned = sb.ToString().Trim('.', '_');
|
||||
return string.IsNullOrEmpty(cleaned) ? "attachment" : cleaned;
|
||||
}
|
||||
|
||||
private static string SanitizeLangSegment(string? lang)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lang))
|
||||
{
|
||||
return "und";
|
||||
}
|
||||
|
||||
var s = lang.Trim().ToLowerInvariant();
|
||||
Span<char> buffer = stackalloc char[s.Length];
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
var ch = s[i];
|
||||
buffer[i] = ch is '_' or '-' or >= 'a' and <= 'z' ? ch : '_';
|
||||
}
|
||||
|
||||
var span = new string(buffer).Trim('_');
|
||||
return string.IsNullOrEmpty(span) ? "und" : span[..Math.Min(span.Length, 24)];
|
||||
}
|
||||
|
||||
private static string NormalizeCodec(string? codecRaw) =>
|
||||
string.IsNullOrWhiteSpace(codecRaw) ? string.Empty : codecRaw.Trim().ToLowerInvariant();
|
||||
}
|
||||
1217
EmbyToolbox/Services/FfmpegCommandBuilder.cs
Normal file
1217
EmbyToolbox/Services/FfmpegCommandBuilder.cs
Normal file
File diff suppressed because it is too large
Load Diff
207
EmbyToolbox/Services/FfmpegEncoderDiscoveryService.cs
Normal file
207
EmbyToolbox/Services/FfmpegEncoderDiscoveryService.cs
Normal file
@ -0,0 +1,207 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed record VideoEncoderSettings(string Codec, string ExtraArguments);
|
||||
|
||||
public sealed class FfmpegEncoderDiscoveryService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private HashSet<string>? _cachedEncoders;
|
||||
|
||||
public HashSet<string> GetAvailableEncoders(LoggingService logging)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_cachedEncoders is not null)
|
||||
{
|
||||
return _cachedEncoders;
|
||||
}
|
||||
|
||||
_cachedEncoders = DiscoverEncoders(logging);
|
||||
return _cachedEncoders;
|
||||
}
|
||||
}
|
||||
|
||||
public VideoEncoderSettings ResolveVideoEncoder(
|
||||
string selectedMode,
|
||||
string targetVideo,
|
||||
bool autoFallbackToCpu,
|
||||
LoggingService logging)
|
||||
{
|
||||
var encoders = GetAvailableEncoders(logging);
|
||||
var family = ResolveTargetFamily(targetVideo);
|
||||
|
||||
if (string.Equals(selectedMode, HardwareAccelerationMode.Auto, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var auto = TryResolveByPriority(encoders, family);
|
||||
if (auto is not null)
|
||||
{
|
||||
logging.Info($"выбран энкодер (Auto): {auto.Codec}", "conversion.hw");
|
||||
return auto;
|
||||
}
|
||||
|
||||
var cpu = BuildCpuSettings(family);
|
||||
logging.Warning("аппаратные энкодеры не найдены, fallback на CPU", "conversion.hw");
|
||||
return cpu;
|
||||
}
|
||||
|
||||
var preferred = TryResolveSpecificMode(selectedMode, encoders, family);
|
||||
if (preferred is not null)
|
||||
{
|
||||
logging.Info($"выбран энкодер ({selectedMode}): {preferred.Codec}", "conversion.hw");
|
||||
return preferred;
|
||||
}
|
||||
|
||||
logging.Error($"выбранный энкодер недоступен: режим={selectedMode}, target={targetVideo}", "conversion.hw");
|
||||
if (!autoFallbackToCpu)
|
||||
{
|
||||
throw new InvalidOperationException($"Недоступен выбранный энкодер: {selectedMode}");
|
||||
}
|
||||
|
||||
var fallback = BuildCpuSettings(family);
|
||||
logging.Warning($"fallback на CPU: {fallback.Codec}", "conversion.hw");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static VideoEncoderSettings? TryResolveByPriority(HashSet<string> encoders, string family)
|
||||
{
|
||||
return TryResolveModeInternal(HardwareAccelerationMode.Nvenc, encoders, family)
|
||||
?? TryResolveModeInternal(HardwareAccelerationMode.Qsv, encoders, family)
|
||||
?? TryResolveModeInternal(HardwareAccelerationMode.Amf, encoders, family);
|
||||
}
|
||||
|
||||
private static VideoEncoderSettings? TryResolveSpecificMode(string selectedMode, HashSet<string> encoders, string family)
|
||||
{
|
||||
if (string.Equals(selectedMode, HardwareAccelerationMode.Cpu, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildCpuSettings(family);
|
||||
}
|
||||
|
||||
return TryResolveModeInternal(selectedMode, encoders, family);
|
||||
}
|
||||
|
||||
private static VideoEncoderSettings? TryResolveModeInternal(string mode, HashSet<string> encoders, string family)
|
||||
{
|
||||
var codec = family == "h265"
|
||||
? mode.ToUpperInvariant() switch
|
||||
{
|
||||
"NVENC" => "hevc_nvenc",
|
||||
"QSV" => "hevc_qsv",
|
||||
"AMF" => "hevc_amf",
|
||||
_ => string.Empty
|
||||
}
|
||||
: mode.ToUpperInvariant() switch
|
||||
{
|
||||
"NVENC" => "h264_nvenc",
|
||||
"QSV" => "h264_qsv",
|
||||
"AMF" => "h264_amf",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(codec) || !encoders.Contains(codec))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var extra = mode.ToUpperInvariant() switch
|
||||
{
|
||||
"NVENC" => "-preset p5 -cq 23 -b:v 0",
|
||||
"QSV" => "-global_quality 23",
|
||||
"AMF" => "-quality quality -rc cqp -qp_i 23 -qp_p 23",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
return new VideoEncoderSettings(codec, extra);
|
||||
}
|
||||
|
||||
private static VideoEncoderSettings BuildCpuSettings(string family)
|
||||
{
|
||||
var codec = family == "h265" ? "libx265" : "libx264";
|
||||
return new VideoEncoderSettings(codec, "-preset medium -crf 23");
|
||||
}
|
||||
|
||||
private static string ResolveTargetFamily(string targetVideo)
|
||||
{
|
||||
if (targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase) || targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "h265";
|
||||
}
|
||||
|
||||
return "h264";
|
||||
}
|
||||
|
||||
private static HashSet<string> DiscoverEncoders(LoggingService logging)
|
||||
{
|
||||
var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe");
|
||||
if (!File.Exists(ffmpegPath))
|
||||
{
|
||||
logging.Error($"не найден ffmpeg: {ffmpegPath}", "conversion.hw");
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = "-hide_banner -encoders",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = start };
|
||||
process.Start();
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
var text = string.Concat(stdout, Environment.NewLine, stderr);
|
||||
var found = ParseEncoderNames(text);
|
||||
logging.Info(
|
||||
$"обнаружено энкодеров ffmpeg: {found.Count}. hw: {FormatHwSummary(found)}",
|
||||
"conversion.hw",
|
||||
command: $"{ffmpegPath} -hide_banner -encoders",
|
||||
stdout: text);
|
||||
return found;
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseEncoderNames(string text)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var t = line.TrimStart();
|
||||
if (!t.StartsWith('V') && !t.StartsWith("V.", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = t.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
result.Add(parts[1].Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string FormatHwSummary(HashSet<string> encoders)
|
||||
{
|
||||
var known = new[]
|
||||
{
|
||||
"h264_nvenc", "hevc_nvenc",
|
||||
"h264_qsv", "hevc_qsv",
|
||||
"h264_amf", "hevc_amf",
|
||||
"libx264", "libx265"
|
||||
};
|
||||
var present = known.Where(encoders.Contains).ToArray();
|
||||
return present.Length == 0 ? "none" : string.Join(", ", present);
|
||||
}
|
||||
}
|
||||
386
EmbyToolbox/Services/FfmpegService.cs
Normal file
386
EmbyToolbox/Services/FfmpegService.cs
Normal file
@ -0,0 +1,386 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed record FfmpegProgressSnapshot(int? Percent, bool IsIndeterminate, string StatusText);
|
||||
public sealed record FfmpegRunResult(bool Success, string StdErr, int ExitCode);
|
||||
|
||||
/// <summary>
|
||||
/// Запускает ffmpeg с <c>-progress pipe:1 -nostats</c>: асинхронно читает stdout (прогресс) и stderr
|
||||
/// (обязательно, чтобы не заблокировался процесс), не блокируя UI.
|
||||
/// </summary>
|
||||
public sealed class FfmpegService
|
||||
{
|
||||
private const int MinProgressReportIntervalMs = 300;
|
||||
private const string StatusRunning = "В работе";
|
||||
|
||||
public async Task<FfmpegRunResult> RunAsync(
|
||||
FfmpegCommand command,
|
||||
MediaAnalysisResult? media,
|
||||
IProgress<FfmpegProgressSnapshot>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = command.Executable,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
foreach (var a in command.ArgumentList)
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
}
|
||||
|
||||
using var p = new Process { StartInfo = start };
|
||||
|
||||
p.Start();
|
||||
// stderr обязан читаться параллельно с progress на stdout, иначе буфер заполняется и процесс встанет
|
||||
var stderrV = p.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
using (cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state is Process proc && !proc.HasExited)
|
||||
{
|
||||
proc.Kill(true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
p))
|
||||
{
|
||||
try
|
||||
{
|
||||
var totalDurationMs = ResolveTotalDurationMs(media, out var totalFrameEstimate);
|
||||
await ReadProgressFromStdoutAsync(
|
||||
p,
|
||||
totalDurationMs,
|
||||
totalFrameEstimate,
|
||||
progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string errText;
|
||||
try
|
||||
{
|
||||
errText = await stderrV.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
errText = string.Empty;
|
||||
}
|
||||
|
||||
return new FfmpegRunResult(p.ExitCode == 0, errText, p.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="totalFrameEstimate"/> — только для fallback, если в прогресс-стриме нет времени.
|
||||
/// </summary>
|
||||
private static double? ResolveTotalDurationMs(MediaAnalysisResult? media, out double? totalFrameEstimate)
|
||||
{
|
||||
totalFrameEstimate = null;
|
||||
if (media is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sec = media.GetEffectiveDurationSeconds();
|
||||
if (sec is not { } d || d <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var totalMs = d * 1000.0;
|
||||
var fps = media.PrimaryVideo?.FrameRate;
|
||||
if (fps is { } f && f > 0)
|
||||
{
|
||||
totalFrameEstimate = f * d;
|
||||
}
|
||||
|
||||
return totalMs;
|
||||
}
|
||||
|
||||
private static async Task ReadProgressFromStdoutAsync(
|
||||
Process p,
|
||||
double? totalDurationMs,
|
||||
double? totalFrameEstimate,
|
||||
IProgress<FfmpegProgressSnapshot>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outReader = p.StandardOutput;
|
||||
var lastReportedPercent = -1;
|
||||
var lastReportTime = long.MinValue;
|
||||
var anyTimeProgress = false;
|
||||
|
||||
string? line;
|
||||
while ((line = await outReader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
if (line.Equals("progress=end", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 100% фазы кодирования (снаружи маппится 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// out_time_ms / out_time_us / out_time. Значения из key=value по документации ffmpeg: *_ms часто = микросекунды; *_us = микросекунды.
|
||||
/// </summary>
|
||||
private static bool TryParseTimeProgressMs(string line, double? totalDurationMs, out double outTimeMs)
|
||||
{
|
||||
outTimeMs = 0;
|
||||
if (line.StartsWith("out_time_ms=", StringComparison.Ordinal))
|
||||
{
|
||||
return TryParseOutTimeMsField(line["out_time_ms=".Length..], totalDurationMs, out outTimeMs);
|
||||
}
|
||||
|
||||
if (line.StartsWith("out_time_us=", StringComparison.Ordinal))
|
||||
{
|
||||
return TryParseOutTimeUsField(line["out_time_us=".Length..], out outTimeMs);
|
||||
}
|
||||
|
||||
if (line.StartsWith("out_time=", StringComparison.Ordinal))
|
||||
{
|
||||
return TryParseOutTimeString(line["out_time=".Length..], out outTimeMs);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Значение out_time_ms: встречается и как мс, и как мкс; выбираем согласованно с <paramref name="totalDurationMs"/>.</summary>
|
||||
private static bool TryParseOutTimeMsField(string raw, double? totalDurationMs, out double outTimeMs)
|
||||
{
|
||||
outTimeMs = 0;
|
||||
var s = raw.AsSpan().Trim();
|
||||
if (s.Length == 0 || s.Equals("N/A", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (n < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var asTrueMs = n;
|
||||
var asFromMicro = n / 1000.0;
|
||||
if (totalDurationMs is { } t && t > 0)
|
||||
{
|
||||
var leeway = 5000.0;
|
||||
if (asTrueMs <= t + leeway)
|
||||
{
|
||||
outTimeMs = asTrueMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (asFromMicro <= t + leeway)
|
||||
{
|
||||
outTimeMs = asFromMicro;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
outTimeMs = asFromMicro;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseOutTimeUsField(string raw, out double outTimeMs)
|
||||
{
|
||||
outTimeMs = 0;
|
||||
var s = raw.AsSpan().Trim();
|
||||
if (s.Length == 0 || s.Equals("N/A", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
outTimeMs = n / 1000.0; // микросекунды → мс
|
||||
return outTimeMs >= 0;
|
||||
}
|
||||
|
||||
private static bool TryParseOutTimeString(string raw, out double outTimeMs)
|
||||
{
|
||||
outTimeMs = 0;
|
||||
var t = raw.AsSpan().Trim();
|
||||
if (t.Length == 0 || t.Equals("N/A", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (t.Contains(':'))
|
||||
{
|
||||
// HH:MM:SS[.fract] или 00:00:01.00
|
||||
var c = 0;
|
||||
for (var i = 0; i < t.Length; i++)
|
||||
{
|
||||
if (t[i] == ':')
|
||||
{
|
||||
c++;
|
||||
}
|
||||
}
|
||||
|
||||
if (c == 2)
|
||||
{
|
||||
var p1 = t.IndexOf(':');
|
||||
var p2 = t[(p1 + 1)..].IndexOf(':') + p1 + 1;
|
||||
if (p1 > 0 && p2 > p1 &&
|
||||
int.TryParse(t[..p1], out var h) &&
|
||||
int.TryParse(t[(p1 + 1)..p2], out var m) &&
|
||||
double.TryParse(t[(p2 + 1)..], NumberStyles.Any, CultureInfo.InvariantCulture, out var sec))
|
||||
{
|
||||
outTimeMs = (h * 3600.0 + m * 60.0 + sec) * 1000.0;
|
||||
return outTimeMs >= 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (double.TryParse(t, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
|
||||
{
|
||||
outTimeMs = seconds * 1000.0;
|
||||
return outTimeMs >= 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetFrameCount(string line, out int frame)
|
||||
{
|
||||
frame = 0;
|
||||
if (!line.StartsWith("frame=", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var i = 6;
|
||||
while (i < line.Length && char.IsWhiteSpace(line[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i >= line.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var f = 0;
|
||||
var any = false;
|
||||
for (; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (!char.IsDigit(c))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
f = f * 10 + (c - '0');
|
||||
any = true;
|
||||
}
|
||||
|
||||
if (!any)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
frame = f;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
171
EmbyToolbox/Services/FfprobeAudioInfoParser.cs
Normal file
171
EmbyToolbox/Services/FfprobeAudioInfoParser.cs
Normal file
@ -0,0 +1,171 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Извлечение количества аудиодорожек и оценка суммарного размера аудио по JSON ffprobe.
|
||||
/// </summary>
|
||||
public static class FfprobeAudioInfoParser
|
||||
{
|
||||
public static FfprobeAudioInfo? TryParse(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return ParseRoot(doc.RootElement);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static FfprobeAudioInfo? ParseRoot(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double? formatDurationSec = null;
|
||||
if (root.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
formatDurationSec = TryReadDurationSeconds(format);
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("streams", out var streams) || streams.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new FfprobeAudioInfo(0, null, false);
|
||||
}
|
||||
|
||||
var audioList = new List<JsonElement>();
|
||||
foreach (var stream in streams.EnumerateArray())
|
||||
{
|
||||
if (stream.ValueKind == JsonValueKind.Object &&
|
||||
stream.TryGetProperty("codec_type", out var ct) &&
|
||||
ct.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(ct.GetString(), "audio", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
audioList.Add(stream);
|
||||
}
|
||||
}
|
||||
|
||||
var count = audioList.Count;
|
||||
if (count == 0)
|
||||
{
|
||||
return new FfprobeAudioInfo(0, 0, false);
|
||||
}
|
||||
|
||||
var partial = false;
|
||||
var totalBytes = 0.0;
|
||||
var anySize = false;
|
||||
|
||||
foreach (var stream in audioList)
|
||||
{
|
||||
var duration = TryReadStreamDurationSeconds(stream) ?? formatDurationSec;
|
||||
var bps = TryReadBitrateBps(stream);
|
||||
if (duration is null || bps is null)
|
||||
{
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (double.IsNaN(duration.Value) || double.IsInfinity(duration.Value) || duration.Value <= 0)
|
||||
{
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bps.Value <= 0)
|
||||
{
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
totalBytes += duration.Value * (bps.Value / 8.0);
|
||||
anySize = true;
|
||||
}
|
||||
|
||||
int? sizeMb;
|
||||
if (!anySize)
|
||||
{
|
||||
sizeMb = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var mb = totalBytes / (1024.0 * 1024.0);
|
||||
sizeMb = (int)Math.Round(mb, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
return new FfprobeAudioInfo(count, sizeMb, partial);
|
||||
}
|
||||
|
||||
private static double? TryReadDurationSeconds(JsonElement el)
|
||||
{
|
||||
if (el.TryGetProperty("duration", out var d) && d.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (double.TryParse(d.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var s))
|
||||
{
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? TryReadStreamDurationSeconds(JsonElement stream)
|
||||
{
|
||||
return TryReadDurationSeconds(stream);
|
||||
}
|
||||
|
||||
private static long? TryReadBitrateBps(JsonElement stream)
|
||||
{
|
||||
if (stream.TryGetProperty("bit_rate", out var br) && br.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var s = br.GetString();
|
||||
if (long.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var b) && b > 0)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.TryGetProperty("max_bit_rate", out var mbr) && mbr.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var s = mbr.GetString();
|
||||
if (long.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var b) && b > 0)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.TryGetProperty("tags", out var tags) && tags.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var p in tags.EnumerateObject())
|
||||
{
|
||||
if (p.Name.Contains("BPS", StringComparison.OrdinalIgnoreCase) &&
|
||||
p.Value.ValueKind is JsonValueKind.String or JsonValueKind.Number)
|
||||
{
|
||||
if (p.Value.ValueKind == JsonValueKind.String && long.TryParse(p.Value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var b) && b > 0)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
|
||||
if (p.Value.ValueKind == JsonValueKind.Number && p.Value.TryGetInt64(out var n) && n > 0)
|
||||
{
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct FfprobeAudioInfo(int AudioStreamCount, int? AudioSizeMbTotal, bool IsPartial);
|
||||
145
EmbyToolbox/Services/FfprobeService.cs
Normal file
145
EmbyToolbox/Services/FfprobeService.cs
Normal file
@ -0,0 +1,145 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class FfprobeService
|
||||
{
|
||||
public async Task<FfprobeResult> AnalyzeAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
=> await AnalyzeInternalAsync(filePath, "-v error -show_format -show_streams -show_chapters -print_format json", cancellationToken);
|
||||
|
||||
public async Task<FfprobeResult> AnalyzeSubtitlePacketsAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
=> await AnalyzeInternalAsync(
|
||||
filePath,
|
||||
"-v error -select_streams s -show_packets -show_entries packet=stream_index,duration_time -print_format json",
|
||||
cancellationToken);
|
||||
|
||||
private async Task<FfprobeResult> AnalyzeInternalAsync(string filePath, string argumentPrefix, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return FfprobeResult.Fail("Файл не выбран или не существует.");
|
||||
}
|
||||
|
||||
var ffprobePath = ResolveFfprobePath();
|
||||
if (!File.Exists(ffprobePath))
|
||||
{
|
||||
return FfprobeResult.Fail($"ffprobe не найден: {ffprobePath}");
|
||||
}
|
||||
|
||||
var arguments = $"{argumentPrefix} \"{filePath}\"";
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffprobePath,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
process.Start();
|
||||
using var killRegistration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state is not Process p || p.HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
p.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
process,
|
||||
useSynchronizationContext: false);
|
||||
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(outputTask, errorTask);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
var output = await outputTask;
|
||||
var error = await errorTask;
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(error)
|
||||
? $"ffprobe завершился с кодом {process.ExitCode}."
|
||||
: error.Trim();
|
||||
return FfprobeResult.Fail(message, $"{ffprobePath} {arguments}", output, error);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return FfprobeResult.Fail("ffprobe вернул пустой JSON-результат.", $"{ffprobePath} {arguments}", output, error);
|
||||
}
|
||||
|
||||
return FfprobeResult.Ok(output, $"{ffprobePath} {arguments}", output, error);
|
||||
}
|
||||
|
||||
private static string ResolveFfprobePath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Tools", "ffprobe.exe");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FfprobeResult
|
||||
{
|
||||
private FfprobeResult(bool isSuccess, string json, string error, string command, string stdOut, string stdErr)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Json = json;
|
||||
Error = error;
|
||||
Command = command;
|
||||
StdOut = stdOut;
|
||||
StdErr = stdErr;
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
public string Json { get; }
|
||||
|
||||
public string Error { get; }
|
||||
|
||||
public string Command { get; }
|
||||
|
||||
public string StdOut { get; }
|
||||
|
||||
public string StdErr { get; }
|
||||
|
||||
public static FfprobeResult Ok(string json, string command, string stdOut, string stdErr)
|
||||
{
|
||||
return new FfprobeResult(true, json, string.Empty, command, stdOut, stdErr);
|
||||
}
|
||||
|
||||
public static FfprobeResult Fail(string error, string command = "", string stdOut = "", string stdErr = "")
|
||||
{
|
||||
return new FfprobeResult(false, string.Empty, error, command, stdOut, stdErr);
|
||||
}
|
||||
}
|
||||
|
||||
115
EmbyToolbox/Services/FileDiscoveryService.cs
Normal file
115
EmbyToolbox/Services/FileDiscoveryService.cs
Normal file
@ -0,0 +1,115 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class FileDiscoveryService
|
||||
{
|
||||
/// <summary>Стабильная сортировка списка видеофайлов по полному нормализованному пути (без учёта регистра).</summary>
|
||||
public static StringComparer QueuePathOrderComparer { get; } = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает пути к поддерживаемым видео: отдельные файлы, из каталогов — рекурсивно, без дублей.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> CollectVideoFilesFromFileSystemEntries(IEnumerable<string>? entryPaths, Action<string>? onError = null)
|
||||
{
|
||||
if (entryPaths is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entryPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var full = Path.GetFullPath(entry);
|
||||
if (File.Exists(full))
|
||||
{
|
||||
if (SupportedVideoFormats.IsSupportedVideoFile(full))
|
||||
{
|
||||
set.Add(full);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Directory.Exists(full))
|
||||
{
|
||||
foreach (var file in DiscoverVideoFiles(full, onError))
|
||||
{
|
||||
set.Add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SortVideoPathsByFullPath(set);
|
||||
}
|
||||
|
||||
/// <summary>Возвращает новый список путей, отсортированный по <see cref="QueuePathOrderComparer"/> (стабильно).</summary>
|
||||
public static IReadOnlyList<string> SortVideoPathsByFullPath(IEnumerable<string> paths) =>
|
||||
paths.OrderBy(p => p, QueuePathOrderComparer).ToList();
|
||||
|
||||
public IReadOnlyList<string> DiscoverVideoFiles(string rootDirectory, Action<string>? onError = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<string>();
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(rootDirectory);
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var current = pending.Pop();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(current))
|
||||
{
|
||||
if (!SupportedVideoFormats.IsSupportedVideoFile(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string normalized;
|
||||
try
|
||||
{
|
||||
normalized = Path.GetFullPath(file);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(normalized);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onError?.Invoke($"ошибка чтения каталога '{current}': {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var childDirectory in Directory.EnumerateDirectories(current))
|
||||
{
|
||||
pending.Push(childDirectory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onError?.Invoke($"ошибка чтения вложенных каталогов '{current}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return SortVideoPathsByFullPath(result);
|
||||
}
|
||||
|
||||
public bool IsSupportedVideoFile(string path) => SupportedVideoFormats.IsSupportedVideoFile(path);
|
||||
}
|
||||
329
EmbyToolbox/Services/ForcedSubtitleDetector.cs
Normal file
329
EmbyToolbox/Services/ForcedSubtitleDetector.cs
Normal file
@ -0,0 +1,329 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class ForcedSubtitleDetector
|
||||
{
|
||||
private const int ForcedEventsThreshold = 200;
|
||||
private const double ForcedCoverageThreshold = 0.10;
|
||||
private static readonly string[] MarkerTokens =
|
||||
[
|
||||
"forced",
|
||||
"force",
|
||||
"форс",
|
||||
"форсированные",
|
||||
"signs",
|
||||
"songs",
|
||||
"signs & songs",
|
||||
"надписи"
|
||||
];
|
||||
|
||||
private readonly FfprobeService _ffprobe;
|
||||
private readonly LoggingService _logging;
|
||||
|
||||
public ForcedSubtitleDetector(FfprobeService ffprobe, LoggingService logging)
|
||||
{
|
||||
_ffprobe = ffprobe;
|
||||
_logging = logging;
|
||||
}
|
||||
|
||||
public bool IsForcedSubtitle(MediaStreamInfo track, double? mediaDuration) =>
|
||||
!string.IsNullOrWhiteSpace(DetectReason(track, mediaDuration));
|
||||
|
||||
public string? DetectReason(MediaStreamInfo track, double? mediaDuration)
|
||||
{
|
||||
if (track.Kind != MediaStreamKind.Subtitle)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var reasons = new List<string>(4);
|
||||
if (track.IsForcedByDisposition)
|
||||
{
|
||||
reasons.Add("disposition");
|
||||
}
|
||||
|
||||
if (ContainsMarker(track.Title))
|
||||
{
|
||||
reasons.Add("title");
|
||||
}
|
||||
|
||||
if (ContainsMarker(track.FileNameTag))
|
||||
{
|
||||
reasons.Add("filename");
|
||||
}
|
||||
|
||||
var stats = CalculateSubtitleStats(track, mediaDuration);
|
||||
if (stats.EventCount is { } count && count < ForcedEventsThreshold)
|
||||
{
|
||||
reasons.Add("events");
|
||||
}
|
||||
|
||||
if (stats.Coverage is { } coverage && coverage < ForcedCoverageThreshold)
|
||||
{
|
||||
reasons.Add("coverage");
|
||||
}
|
||||
|
||||
return reasons.Count == 0 ? null : string.Join("/", reasons.Distinct(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public SubtitleTrackStats CalculateSubtitleStats(MediaStreamInfo track, double? mediaDuration)
|
||||
{
|
||||
var events = track.SubtitleEventCount;
|
||||
var coverage = track.SubtitleCoverage;
|
||||
if (coverage is null
|
||||
&& mediaDuration is > 0
|
||||
&& track.DurationSeconds is { } streamDuration
|
||||
&& streamDuration > 0)
|
||||
{
|
||||
coverage = streamDuration / mediaDuration.Value;
|
||||
}
|
||||
|
||||
return new SubtitleTrackStats(events, coverage);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, SubtitleTrackStats>> CalculateSubtitleStats(
|
||||
string videoPath,
|
||||
double? mediaDuration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var probe = await _ffprobe.AnalyzeSubtitlePacketsAsync(videoPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!probe.IsSuccess)
|
||||
{
|
||||
_logging.Debug($"forced subtitle stats skipped: {probe.Error}", "subtitle.forced");
|
||||
return new Dictionary<int, SubtitleTrackStats>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(probe.Json);
|
||||
if (!doc.RootElement.TryGetProperty("packets", out var packets) || packets.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new Dictionary<int, SubtitleTrackStats>();
|
||||
}
|
||||
|
||||
var aggregate = new Dictionary<int, SubtitleStatsAccumulator>();
|
||||
foreach (var packet in packets.EnumerateArray())
|
||||
{
|
||||
if (!packet.TryGetProperty("stream_index", out var streamIndexRaw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var streamIndex = ParseInt(streamIndexRaw);
|
||||
if (streamIndex is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!aggregate.TryGetValue(streamIndex.Value, out var current))
|
||||
{
|
||||
current = new SubtitleStatsAccumulator();
|
||||
}
|
||||
|
||||
current.EventCount++;
|
||||
if (packet.TryGetProperty("duration_time", out var durationTimeRaw))
|
||||
{
|
||||
var duration = ParseDouble(durationTimeRaw);
|
||||
if (duration is > 0)
|
||||
{
|
||||
current.TotalDurationSeconds += duration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
aggregate[streamIndex.Value] = current;
|
||||
}
|
||||
|
||||
var result = new Dictionary<int, SubtitleTrackStats>(aggregate.Count);
|
||||
foreach (var (streamIndex, stats) in aggregate)
|
||||
{
|
||||
double? coverage = null;
|
||||
if (mediaDuration is > 0 && stats.TotalDurationSeconds > 0)
|
||||
{
|
||||
coverage = stats.TotalDurationSeconds / mediaDuration.Value;
|
||||
}
|
||||
|
||||
result[streamIndex] = new SubtitleTrackStats(stats.EventCount, coverage);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging.Warning($"forced subtitle stats parse failed: {ex.Message}", "subtitle.forced", ex);
|
||||
return new Dictionary<int, SubtitleTrackStats>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MediaAnalysisResult> ApplyDetectionAsync(
|
||||
string videoPath,
|
||||
MediaAnalysisResult media,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (media.SubtitleStreams.Count == 0)
|
||||
{
|
||||
return media;
|
||||
}
|
||||
|
||||
var statsByStream = await CalculateSubtitleStats(videoPath, media.GetEffectiveDurationSeconds(), cancellationToken).ConfigureAwait(false);
|
||||
var updatedAll = new List<MediaStreamInfo>(media.AllStreams.Count);
|
||||
foreach (var stream in media.AllStreams)
|
||||
{
|
||||
if (stream.Kind != MediaStreamKind.Subtitle)
|
||||
{
|
||||
updatedAll.Add(stream);
|
||||
continue;
|
||||
}
|
||||
|
||||
statsByStream.TryGetValue(stream.Index, out var stats);
|
||||
var withStats = new MediaStreamInfo
|
||||
{
|
||||
Index = stream.Index,
|
||||
Kind = stream.Kind,
|
||||
CodecName = stream.CodecName,
|
||||
Language = stream.Language,
|
||||
Title = stream.Title,
|
||||
IsDefault = stream.IsDefault,
|
||||
BitRateBps = stream.BitRateBps,
|
||||
Profile = stream.Profile,
|
||||
Encoder = stream.Encoder,
|
||||
Channels = stream.Channels,
|
||||
SampleRateHz = stream.SampleRateHz,
|
||||
Width = stream.Width,
|
||||
Height = stream.Height,
|
||||
AverageFrameRate = stream.AverageFrameRate,
|
||||
FrameRate = stream.FrameRate,
|
||||
PixelFormat = stream.PixelFormat,
|
||||
ColorSpace = stream.ColorSpace,
|
||||
ColorPrimaries = stream.ColorPrimaries,
|
||||
ColorTransfer = stream.ColorTransfer,
|
||||
SubtitleFormat = stream.SubtitleFormat,
|
||||
FileNameTag = stream.FileNameTag,
|
||||
IsForcedByDisposition = stream.IsForcedByDisposition,
|
||||
SubtitleEventCount = stats.EventCount,
|
||||
SubtitleCoverage = stats.Coverage,
|
||||
AttachmentDeclaredFileName = stream.AttachmentDeclaredFileName,
|
||||
AttachmentDeclaredMimeType = stream.AttachmentDeclaredMimeType,
|
||||
DurationSeconds = stream.DurationSeconds
|
||||
};
|
||||
|
||||
var reason = DetectReason(withStats, media.GetEffectiveDurationSeconds());
|
||||
var isForced = !string.IsNullOrWhiteSpace(reason);
|
||||
var updated = new MediaStreamInfo
|
||||
{
|
||||
Index = withStats.Index,
|
||||
Kind = withStats.Kind,
|
||||
CodecName = withStats.CodecName,
|
||||
Language = withStats.Language,
|
||||
Title = withStats.Title,
|
||||
IsDefault = withStats.IsDefault,
|
||||
BitRateBps = withStats.BitRateBps,
|
||||
Profile = withStats.Profile,
|
||||
Encoder = withStats.Encoder,
|
||||
Channels = withStats.Channels,
|
||||
SampleRateHz = withStats.SampleRateHz,
|
||||
Width = withStats.Width,
|
||||
Height = withStats.Height,
|
||||
AverageFrameRate = withStats.AverageFrameRate,
|
||||
FrameRate = withStats.FrameRate,
|
||||
PixelFormat = withStats.PixelFormat,
|
||||
ColorSpace = withStats.ColorSpace,
|
||||
ColorPrimaries = withStats.ColorPrimaries,
|
||||
ColorTransfer = withStats.ColorTransfer,
|
||||
SubtitleFormat = withStats.SubtitleFormat,
|
||||
FileNameTag = withStats.FileNameTag,
|
||||
IsForcedByDisposition = withStats.IsForcedByDisposition,
|
||||
IsForced = isForced,
|
||||
ForcedDetectionReason = reason,
|
||||
SubtitleEventCount = withStats.SubtitleEventCount,
|
||||
SubtitleCoverage = withStats.SubtitleCoverage,
|
||||
AttachmentDeclaredFileName = withStats.AttachmentDeclaredFileName,
|
||||
AttachmentDeclaredMimeType = withStats.AttachmentDeclaredMimeType,
|
||||
DurationSeconds = withStats.DurationSeconds
|
||||
};
|
||||
|
||||
if (isForced)
|
||||
{
|
||||
_logging.Debug($"forced subtitle detected: {BuildTrackLabel(updated)}", "subtitle.forced");
|
||||
_logging.Debug($"forced detection reason: {reason}", "subtitle.forced");
|
||||
}
|
||||
|
||||
updatedAll.Add(updated);
|
||||
}
|
||||
|
||||
return new MediaAnalysisResult
|
||||
{
|
||||
ContainerFormat = media.ContainerFormat,
|
||||
FormatName = media.FormatName,
|
||||
FormatBitRateBps = media.FormatBitRateBps,
|
||||
DurationSeconds = media.DurationSeconds,
|
||||
SourceVideoBitrateBps = media.SourceVideoBitrateBps,
|
||||
AllStreams = updatedAll,
|
||||
VideoStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Video).ToList(),
|
||||
AudioStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Audio).ToList(),
|
||||
SubtitleStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Subtitle).ToList(),
|
||||
DataStreams = updatedAll.Where(s => s.Kind == MediaStreamKind.Data).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsMarker(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = input.Trim().ToLowerInvariant();
|
||||
return MarkerTokens.Any(normalized.Contains);
|
||||
}
|
||||
|
||||
private static int? ParseInt(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String
|
||||
&& int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? ParseDouble(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String
|
||||
&& double.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildTrackLabel(MediaStreamInfo stream)
|
||||
{
|
||||
var title = string.IsNullOrWhiteSpace(stream.Title) ? "-" : stream.Title;
|
||||
var file = string.IsNullOrWhiteSpace(stream.FileNameTag) ? "-" : stream.FileNameTag;
|
||||
return $"#{stream.Index} | title={title} | file={file}";
|
||||
}
|
||||
|
||||
private struct SubtitleStatsAccumulator
|
||||
{
|
||||
public int EventCount;
|
||||
public double TotalDurationSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct SubtitleTrackStats(int? EventCount, double? Coverage);
|
||||
6
EmbyToolbox/Services/IProfileSettingsProvider.cs
Normal file
6
EmbyToolbox/Services/IProfileSettingsProvider.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public interface IProfileSettingsProvider
|
||||
{
|
||||
ConversionProfileSettingsEntry? GetProfile(string name);
|
||||
}
|
||||
9
EmbyToolbox/Services/LogLevel.cs
Normal file
9
EmbyToolbox/Services/LogLevel.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public enum LogLevel
|
||||
{
|
||||
Debug = 0,
|
||||
Info = 1,
|
||||
Warning = 2,
|
||||
Error = 3
|
||||
}
|
||||
141
EmbyToolbox/Services/LoggingService.cs
Normal file
141
EmbyToolbox/Services/LoggingService.cs
Normal file
@ -0,0 +1,141 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using EmbyToolbox.ViewModels;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class LoggingService
|
||||
{
|
||||
private const int UiLogLimit = 1000;
|
||||
private readonly string _logsDirectory;
|
||||
|
||||
public LoggingService()
|
||||
{
|
||||
_logsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"EmbyToolbox",
|
||||
"Logs");
|
||||
Directory.CreateDirectory(_logsDirectory);
|
||||
}
|
||||
|
||||
public ObservableCollection<LogEntryViewModel> UiEntries { get; } = new();
|
||||
|
||||
public LogLevel MinimumFileLogLevel { get; set; } = LogLevel.Info;
|
||||
|
||||
public string LogsDirectory => _logsDirectory;
|
||||
|
||||
public void Debug(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
|
||||
{
|
||||
Write(LogLevel.Debug, message, module, exception, command, stdout, stderr);
|
||||
}
|
||||
|
||||
public void Info(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
|
||||
{
|
||||
Write(LogLevel.Info, message, module, exception, command, stdout, stderr);
|
||||
}
|
||||
|
||||
public void Warning(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
|
||||
{
|
||||
Write(LogLevel.Warning, message, module, exception, command, stdout, stderr);
|
||||
}
|
||||
|
||||
public void Error(string message, string module = "app", Exception? exception = null, string? command = null, string? stdout = null, string? stderr = null)
|
||||
{
|
||||
Write(LogLevel.Error, message, module, exception, command, stdout, stderr);
|
||||
}
|
||||
|
||||
public void ClearUi()
|
||||
{
|
||||
UiEntries.Clear();
|
||||
}
|
||||
|
||||
private void Write(LogLevel level, string message, string module, Exception? exception, string? command, string? stdout, string? stderr)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var entry = new LogEntryViewModel
|
||||
{
|
||||
Timestamp = now,
|
||||
Level = level,
|
||||
LevelText = level.ToString(),
|
||||
Module = module,
|
||||
Message = message
|
||||
};
|
||||
|
||||
AddUiEntryThreadSafe(entry);
|
||||
|
||||
if (level < MinimumFileLogLevel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteToFile(now, level, module, message, exception, command, stdout, stderr);
|
||||
}
|
||||
|
||||
private void AddUiEntryThreadSafe(LogEntryViewModel entry)
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher is null || dispatcher.CheckAccess())
|
||||
{
|
||||
UiEntries.Add(entry);
|
||||
while (UiEntries.Count > UiLogLimit)
|
||||
{
|
||||
UiEntries.RemoveAt(0);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.BeginInvoke(
|
||||
DispatcherPriority.DataBind,
|
||||
new Action(
|
||||
() =>
|
||||
{
|
||||
UiEntries.Add(entry);
|
||||
while (UiEntries.Count > UiLogLimit)
|
||||
{
|
||||
UiEntries.RemoveAt(0);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void WriteToFile(DateTime timestamp, LogLevel level, string module, string message, Exception? exception, string? command, string? stdout, string? stderr)
|
||||
{
|
||||
var path = Path.Combine(_logsDirectory, $"app-{timestamp:yyyy-MM-dd}.log");
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append('[').Append(timestamp.ToString("yyyy-MM-dd HH:mm:ss")).Append("] ");
|
||||
sb.Append(level.ToString().ToUpperInvariant()).Append(" ");
|
||||
sb.Append("module=").Append(module).Append(" ");
|
||||
sb.Append("message=").Append(message);
|
||||
sb.AppendLine();
|
||||
|
||||
if (exception is not null)
|
||||
{
|
||||
sb.AppendLine("exception:");
|
||||
sb.AppendLine(exception.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
sb.Append("command: ").AppendLine(command);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stdout))
|
||||
{
|
||||
sb.AppendLine("stdout:");
|
||||
sb.AppendLine(stdout);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
sb.AppendLine("stderr:");
|
||||
sb.AppendLine(stderr);
|
||||
}
|
||||
|
||||
sb.AppendLine(new string('-', 80));
|
||||
File.AppendAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
380
EmbyToolbox/Services/MediaAnalysisParser.cs
Normal file
380
EmbyToolbox/Services/MediaAnalysisParser.cs
Normal file
@ -0,0 +1,380 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Разбор JSON ffprobe в <see cref="MediaAnalysisResult"/>.</summary>
|
||||
public static class MediaAnalysisParser
|
||||
{
|
||||
public static MediaAnalysisResult? TryParse(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return ParseRoot(doc.RootElement);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static MediaAnalysisResult ParseRoot(JsonElement root)
|
||||
{
|
||||
var format = string.Empty;
|
||||
var name = string.Empty;
|
||||
long? formatBitRateBps = null;
|
||||
double? durationSec = null;
|
||||
if (root.TryGetProperty("format", out var fmt) && fmt.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (fmt.TryGetProperty("format_name", out var fn) && fn.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
format = fn.GetString() ?? string.Empty;
|
||||
}
|
||||
if (fmt.TryGetProperty("format_long_name", out var fl) && fl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
name = fl.GetString() ?? string.Empty;
|
||||
}
|
||||
if (fmt.TryGetProperty("duration", out var d))
|
||||
{
|
||||
durationSec = ParseDuration(d);
|
||||
}
|
||||
|
||||
formatBitRateBps = ParseLong(fmt, "bit_rate");
|
||||
}
|
||||
|
||||
var streams = new List<MediaStreamInfo>();
|
||||
if (root.TryGetProperty("streams", out var st) && st.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var e in st.EnumerateArray())
|
||||
{
|
||||
if (e.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var s = TryParseStream(e);
|
||||
if (s is not null)
|
||||
{
|
||||
streams.Add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var primaryVideoBitrate = streams
|
||||
.Where(x => x.Kind == MediaStreamKind.Video)
|
||||
.OrderByDescending(static v => ((long)(v.Width ?? 0)) * (v.Height ?? 0))
|
||||
.ThenByDescending(static v => v.IsDefault ? 1 : 0)
|
||||
.Select(static v => v.BitRateBps)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new MediaAnalysisResult
|
||||
{
|
||||
ContainerFormat = format,
|
||||
FormatName = string.IsNullOrEmpty(name) ? format : name,
|
||||
FormatBitRateBps = formatBitRateBps,
|
||||
DurationSeconds = durationSec,
|
||||
AllStreams = streams,
|
||||
VideoStreams = streams.Where(x => x.Kind == MediaStreamKind.Video).ToList(),
|
||||
AudioStreams = streams.Where(x => x.Kind == MediaStreamKind.Audio).ToList(),
|
||||
SubtitleStreams = streams.Where(x => x.Kind == MediaStreamKind.Subtitle).ToList(),
|
||||
DataStreams = streams.Where(x => x.Kind == MediaStreamKind.Data).ToList(),
|
||||
SourceVideoBitrateBps = primaryVideoBitrate ?? formatBitRateBps
|
||||
};
|
||||
}
|
||||
|
||||
private static MediaStreamInfo? TryParseStream(JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("codec_type", out var ct) || ct.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var streamDuration = GetStreamDuration(s);
|
||||
var t = (ct.GetString() ?? string.Empty).ToLowerInvariant();
|
||||
if (t == "video")
|
||||
{
|
||||
return new MediaStreamInfo
|
||||
{
|
||||
Index = GetInt(s, "index", -1),
|
||||
Kind = MediaStreamKind.Video,
|
||||
CodecName = GetStr(s, "codec_name", "?") ?? "?",
|
||||
Profile = GetStr(s, "profile", null),
|
||||
Encoder = GetTagExact(s, "encoder"),
|
||||
Language = GetTagLang(s),
|
||||
Title = GetTagTitle(s),
|
||||
IsDefault = GetDisposition(s, "default", false),
|
||||
Width = (int?)GetIntNullable(s, "width"),
|
||||
Height = (int?)GetIntNullable(s, "height"),
|
||||
AverageFrameRate = ParseFpsByKey(s, "avg_frame_rate"),
|
||||
FrameRate = ParseFps(s),
|
||||
PixelFormat = GetStr(s, "pix_fmt", null),
|
||||
ColorSpace = GetStr(s, "color_space", null),
|
||||
ColorPrimaries = GetStr(s, "color_primaries", null),
|
||||
ColorTransfer = GetStr(s, "color_transfer", null),
|
||||
BitRateBps = ParseLong(s, "bit_rate") ?? ParseLong(s, "max_bit_rate"),
|
||||
DurationSeconds = streamDuration
|
||||
};
|
||||
}
|
||||
|
||||
if (t == "audio")
|
||||
{
|
||||
return new MediaStreamInfo
|
||||
{
|
||||
Index = GetInt(s, "index", -1),
|
||||
Kind = MediaStreamKind.Audio,
|
||||
CodecName = GetStr(s, "codec_name", "?") ?? "?",
|
||||
Profile = GetStr(s, "profile", null),
|
||||
Encoder = GetTagExact(s, "encoder"),
|
||||
Language = GetTagLang(s),
|
||||
Title = GetTagTitle(s),
|
||||
IsDefault = GetDisposition(s, "default", false),
|
||||
BitRateBps = ParseLong(s, "bit_rate") ?? ParseLong(s, "max_bit_rate"),
|
||||
Channels = (int?)GetIntNullable(s, "channels"),
|
||||
SampleRateHz = (int?)GetIntNullable(s, "sample_rate"),
|
||||
DurationSeconds = streamDuration
|
||||
};
|
||||
}
|
||||
|
||||
if (t == "subtitle")
|
||||
{
|
||||
var isForcedDisposition = GetDisposition(s, "forced", false);
|
||||
return new MediaStreamInfo
|
||||
{
|
||||
Index = GetInt(s, "index", -1),
|
||||
Kind = MediaStreamKind.Subtitle,
|
||||
CodecName = GetStr(s, "codec_name", "?") ?? "?",
|
||||
Profile = GetStr(s, "profile", null),
|
||||
Encoder = GetTagExact(s, "encoder"),
|
||||
Language = GetTagLang(s),
|
||||
Title = GetTagTitle(s),
|
||||
IsDefault = GetDisposition(s, "default", false),
|
||||
IsForcedByDisposition = isForcedDisposition,
|
||||
IsForced = isForcedDisposition,
|
||||
ForcedDetectionReason = isForcedDisposition ? "disposition" : null,
|
||||
SubtitleFormat = GetStr(s, "codec_name", null),
|
||||
FileNameTag = GetTagExact(s, "filename"),
|
||||
DurationSeconds = streamDuration
|
||||
};
|
||||
}
|
||||
|
||||
if (t == "attachment")
|
||||
{
|
||||
return new MediaStreamInfo
|
||||
{
|
||||
Index = GetInt(s, "index", -1),
|
||||
Kind = MediaStreamKind.Attachment,
|
||||
CodecName = "attachment",
|
||||
DurationSeconds = streamDuration,
|
||||
AttachmentDeclaredFileName = GetTagExact(s, "filename"),
|
||||
AttachmentDeclaredMimeType = GetTagExact(s, "mimetype"),
|
||||
};
|
||||
}
|
||||
|
||||
if (t == "data")
|
||||
{
|
||||
return new MediaStreamInfo
|
||||
{
|
||||
Index = GetInt(s, "index", -1),
|
||||
Kind = MediaStreamKind.Data,
|
||||
CodecName = GetStr(s, "codec_name", "data") ?? "data",
|
||||
DurationSeconds = streamDuration
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? GetStreamDuration(JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("duration", out var d))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseDuration(d);
|
||||
}
|
||||
|
||||
private static int GetInt(JsonElement s, string name, int def)
|
||||
{
|
||||
if (s.TryGetProperty(name, out var n))
|
||||
{
|
||||
if (n.ValueKind == JsonValueKind.Number && n.TryGetInt32(out var v))
|
||||
{
|
||||
return v;
|
||||
}
|
||||
|
||||
if (n.ValueKind == JsonValueKind.String && int.TryParse(n.GetString(), out var s2))
|
||||
{
|
||||
return s2;
|
||||
}
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
private static int? GetIntNullable(JsonElement s, string name)
|
||||
{
|
||||
if (s.TryGetProperty(name, out var n) && n.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return n.GetInt32();
|
||||
}
|
||||
|
||||
if (s.TryGetProperty(name, out var s2) && s2.ValueKind == JsonValueKind.String && int.TryParse(s2.GetString(), out var p))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetStr(JsonElement s, string name, string? def)
|
||||
{
|
||||
if (s.TryGetProperty(name, out var n) && n.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return n.GetString() ?? def;
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
private static long? ParseLong(JsonElement s, string name)
|
||||
{
|
||||
if (!s.TryGetProperty(name, out var p))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (p.ValueKind == JsonValueKind.String && long.TryParse(p.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var a))
|
||||
{
|
||||
return a;
|
||||
}
|
||||
|
||||
if (p.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
if (p.TryGetInt64(out var l))
|
||||
{
|
||||
return l;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetTagLang(JsonElement s)
|
||||
{
|
||||
if (s.TryGetProperty("tags", out var t) && t.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (t.TryGetProperty("language", out var l) && l.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return l.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetTagTitle(JsonElement s)
|
||||
{
|
||||
if (s.TryGetProperty("tags", out var t) && t.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (t.TryGetProperty("title", out var l) && l.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return l.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetTagExact(JsonElement stream, string tagKey)
|
||||
{
|
||||
if (!stream.TryGetProperty("tags", out var tags) || tags.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!tags.TryGetProperty(tagKey, out var val) || val.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return val.GetString();
|
||||
}
|
||||
|
||||
private static bool GetDisposition(JsonElement s, string d, bool def)
|
||||
{
|
||||
if (s.TryGetProperty("disposition", out var dis) && dis.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (dis.TryGetProperty(d, out var v))
|
||||
{
|
||||
if (v.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return v.GetInt32() != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
private static double? ParseFps(JsonElement s)
|
||||
{
|
||||
return ParseFpsByKey(s, "r_frame_rate");
|
||||
}
|
||||
|
||||
private static double? ParseFpsByKey(JsonElement s, string key)
|
||||
{
|
||||
if (!s.TryGetProperty(key, out var f) || f.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseFpsString(f.GetString() ?? string.Empty);
|
||||
}
|
||||
|
||||
private static double? ParseFpsString(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s == "0/0")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var p = s.IndexOf('/');
|
||||
if (p > 0)
|
||||
{
|
||||
if (int.TryParse(s.AsSpan(0, p), out var a) && int.TryParse(s.AsSpan(p + 1), out var b) && b != 0)
|
||||
{
|
||||
return a / (double)b;
|
||||
}
|
||||
}
|
||||
|
||||
if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
return d;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? ParseDuration(JsonElement d)
|
||||
{
|
||||
if (d.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return d.GetDouble();
|
||||
}
|
||||
|
||||
if (d.ValueKind == JsonValueKind.String &&
|
||||
double.TryParse(d.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
|
||||
{
|
||||
return v;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
230
EmbyToolbox/Services/MergeService.cs
Normal file
230
EmbyToolbox/Services/MergeService.cs
Normal file
@ -0,0 +1,230 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class MergeService
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
private readonly Func<string?> _tempDirectoryProvider;
|
||||
private readonly LoggingService _logging;
|
||||
private readonly FfprobeService _ffprobeService;
|
||||
private readonly ChapterBuilderService _chapterBuilderService;
|
||||
|
||||
public MergeService(
|
||||
LoggingService logging,
|
||||
FfprobeService ffprobeService,
|
||||
ChapterBuilderService chapterBuilderService,
|
||||
Func<string?>? tempDirectoryProvider = null)
|
||||
{
|
||||
_logging = logging;
|
||||
_ffprobeService = ffprobeService;
|
||||
_chapterBuilderService = chapterBuilderService;
|
||||
_tempDirectoryProvider = tempDirectoryProvider ?? (() => null);
|
||||
}
|
||||
|
||||
public async Task MergeAsync(
|
||||
IReadOnlyList<MergeFileItem> orderedFiles,
|
||||
string outputPath,
|
||||
IProgress<int>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (orderedFiles.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException("Для объединения нужно минимум 2 файла.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
throw new InvalidOperationException("Не задан путь итогового файла.");
|
||||
}
|
||||
|
||||
var configuredTempRoot = ResolveTempRoot();
|
||||
var tempDirectory = Path.Combine(configuredTempRoot, "Merge", Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
|
||||
var tempOutputPath = Path.Combine(tempDirectory, $"{Path.GetFileNameWithoutExtension(outputPath)}.mkv");
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
_logging.Info($"объединение: подготовка файлов ({orderedFiles.Count}), temp={tempDirectory}", "merge");
|
||||
var durations = new List<double>(orderedFiles.Count);
|
||||
foreach (var file in orderedFiles)
|
||||
{
|
||||
durations.Add(await ResolveDurationSecondsAsync(file.FullPath, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
var totalDurationMs = Math.Max(1.0, durations.Sum() * 1000.0);
|
||||
var chapters = orderedFiles.Select(f => f.PartName).ToList();
|
||||
var metadataText = _chapterBuilderService.BuildFfmetadata(chapters, durations);
|
||||
var concatListPath = Path.Combine(tempDirectory, "merge-list.txt");
|
||||
var metadataPath = Path.Combine(tempDirectory, "chapters.ffmeta");
|
||||
await File.WriteAllTextAsync(concatListPath, BuildConcatList(orderedFiles), Utf8NoBom, cancellationToken).ConfigureAwait(false);
|
||||
await File.WriteAllTextAsync(metadataPath, metadataText, Utf8NoBom, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe");
|
||||
if (!File.Exists(ffmpegPath))
|
||||
{
|
||||
throw new FileNotFoundException($"ffmpeg не найден: {ffmpegPath}");
|
||||
}
|
||||
|
||||
var args = $"-hide_banner -y -progress pipe:1 -nostats -f concat -safe 0 -i \"{concatListPath}\" -i \"{metadataPath}\" -map 0 -map_metadata 1 -c copy \"{tempOutputPath}\"";
|
||||
_logging.Info($"объединение: запуск ffmpeg (temp output) -> {tempOutputPath}", "merge", command: $"{ffmpegPath} {args}");
|
||||
|
||||
var runResult = await RunFfmpegAsync(ffmpegPath, args, totalDurationMs, progress, cancellationToken).ConfigureAwait(false);
|
||||
if (runResult.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"ffmpeg завершился с кодом {runResult.ExitCode}: {runResult.StdErr}");
|
||||
}
|
||||
|
||||
if (!File.Exists(tempOutputPath))
|
||||
{
|
||||
throw new IOException("Временный итоговый файл не был создан.");
|
||||
}
|
||||
|
||||
progress?.Report(94);
|
||||
await MoveTempOutputToFinalPathAsync(tempOutputPath, outputPath, cancellationToken).ConfigureAwait(false);
|
||||
progress?.Report(98);
|
||||
_logging.Info($"объединение завершено успешно: {outputPath}", "merge");
|
||||
progress?.Report(100);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore temp cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Task MoveTempOutputToFinalPathAsync(string tempOutputPath, string finalOutputPath, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(finalOutputPath)!);
|
||||
|
||||
if (File.Exists(finalOutputPath))
|
||||
{
|
||||
File.Delete(finalOutputPath);
|
||||
}
|
||||
|
||||
File.Move(tempOutputPath, finalOutputPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string ResolveTempRoot()
|
||||
{
|
||||
var configured = _tempDirectoryProvider.Invoke();
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return configured.Trim();
|
||||
}
|
||||
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"EmbyToolbox",
|
||||
"Temp");
|
||||
}
|
||||
|
||||
private async Task<double> ResolveDurationSecondsAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var probeResult = await _ffprobeService.AnalyzeAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
if (!probeResult.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException($"ffprobe не смог получить длительность для '{filePath}': {probeResult.Error}");
|
||||
}
|
||||
|
||||
var parsed = MediaAnalysisParser.TryParse(probeResult.Json);
|
||||
var duration = parsed?.GetEffectiveDurationSeconds();
|
||||
if (duration is not { } d || d <= 0.001)
|
||||
{
|
||||
throw new InvalidOperationException($"Не удалось определить длительность файла: {filePath}");
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
private static string BuildConcatList(IEnumerable<MergeFileItem> files)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var escaped = file.FullPath.Replace("'", "'\\''", StringComparison.Ordinal);
|
||||
sb.Append("file '").Append(escaped).AppendLine("'");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string StdErr)> RunFfmpegAsync(
|
||||
string executable,
|
||||
string arguments,
|
||||
double totalDurationMs,
|
||||
IProgress<int>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
process.Start();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
using var killRegistration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state is Process p && !p.HasExited)
|
||||
{
|
||||
p.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
process,
|
||||
useSynchronizationContext: false);
|
||||
|
||||
string? line;
|
||||
while ((line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
if (!line.StartsWith("out_time_ms=", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var raw = line["out_time_ms=".Length..];
|
||||
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var outTimeMicro))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var percent = (int)Math.Clamp((outTimeMicro / 1000.0) / totalDurationMs * 100.0, 0.0, 100.0);
|
||||
progress?.Report(percent);
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var stdErr = await stderrTask.ConfigureAwait(false);
|
||||
return (process.ExitCode, stdErr);
|
||||
}
|
||||
}
|
||||
58
EmbyToolbox/Services/MpegTsTimestampHelpers.cs
Normal file
58
EmbyToolbox/Services/MpegTsTimestampHelpers.cs
Normal file
@ -0,0 +1,58 @@
|
||||
using System.IO;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>MPEG-TS remux: генерация PTS и fallback при ошибках mux.</summary>
|
||||
public static class MpegTsTimestampHelpers
|
||||
{
|
||||
/// <summary>Входной файл распознан как MPEG Transport Stream (.ts / m2ts или format_name mpegts).</summary>
|
||||
public static bool IsMpegTsInput(MediaAnalysisResult? media, string? fileName)
|
||||
{
|
||||
if (media is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var blob = ((media.ContainerFormat ?? string.Empty) + "," + (media.FormatName ?? string.Empty)).ToLowerInvariant();
|
||||
if (blob.Contains("mpegts", StringComparison.Ordinal)
|
||||
|| blob.Contains("mpeg-ts", StringComparison.Ordinal)
|
||||
|| blob.Contains("m2ts", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
var ext = Path.GetExtension(fileName);
|
||||
if (ext.Equals(".ts", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".m2ts", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".mts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Сообщения ffmpeg при copy/remux без валидных PTS.</summary>
|
||||
public static bool LooksLikeTimestampMuxFailure(string? stderr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var s = stderr.AsSpan();
|
||||
return ContainsLoose(s, "unknown timestamp")
|
||||
|| ContainsLoose(s, "Timestamps are unset")
|
||||
|| ContainsLoose(s, "unset in a packet")
|
||||
|| ContainsLoose(s, "Can't write packet with unknown timestamp");
|
||||
}
|
||||
|
||||
private static bool ContainsLoose(ReadOnlySpan<char> haystack, string needle)
|
||||
{
|
||||
return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
78
EmbyToolbox/Services/NaturalStringComparer.cs
Normal file
78
EmbyToolbox/Services/NaturalStringComparer.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class NaturalStringComparer : IComparer<string>, IComparer
|
||||
{
|
||||
public static readonly NaturalStringComparer Instance = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var ix = 0;
|
||||
var iy = 0;
|
||||
while (ix < x.Length && iy < y.Length)
|
||||
{
|
||||
var cx = x[ix];
|
||||
var cy = y[iy];
|
||||
|
||||
if (char.IsDigit(cx) && char.IsDigit(cy))
|
||||
{
|
||||
var startX = ix;
|
||||
var startY = iy;
|
||||
while (ix < x.Length && char.IsDigit(x[ix])) ix++;
|
||||
while (iy < y.Length && char.IsDigit(y[iy])) iy++;
|
||||
|
||||
var partX = x[startX..ix].TrimStart('0');
|
||||
var partY = y[startY..iy].TrimStart('0');
|
||||
if (partX.Length != partY.Length)
|
||||
{
|
||||
return partX.Length.CompareTo(partY.Length);
|
||||
}
|
||||
|
||||
var compareNumeric = string.Compare(partX, partY, StringComparison.Ordinal);
|
||||
if (compareNumeric != 0)
|
||||
{
|
||||
return compareNumeric;
|
||||
}
|
||||
|
||||
var rawX = x[startX..ix];
|
||||
var rawY = y[startY..iy];
|
||||
if (rawX.Length != rawY.Length)
|
||||
{
|
||||
return rawX.Length.CompareTo(rawY.Length);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var cmp = char.ToUpper(cx, CultureInfo.InvariantCulture).CompareTo(char.ToUpper(cy, CultureInfo.InvariantCulture));
|
||||
if (cmp != 0)
|
||||
{
|
||||
return cmp;
|
||||
}
|
||||
|
||||
ix++;
|
||||
iy++;
|
||||
}
|
||||
}
|
||||
|
||||
return x.Length.CompareTo(y.Length);
|
||||
}
|
||||
|
||||
int IComparer.Compare(object? x, object? y) => Compare(x as string, y as string);
|
||||
}
|
||||
251
EmbyToolbox/Services/NotificationService.cs
Normal file
251
EmbyToolbox/Services/NotificationService.cs
Normal file
@ -0,0 +1,251 @@
|
||||
using System.Globalization;
|
||||
using System.Media;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Windows.Threading;
|
||||
using Windows.Data.Xml.Dom;
|
||||
using Windows.UI.Notifications;
|
||||
using EmbyToolbox.Interop;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Звуковые и toast-уведомления после обработки очереди конвертации.</summary>
|
||||
public sealed class NotificationService
|
||||
{
|
||||
/// <summary>Должен совпадать с SetCurrentProcessExplicitAppUserModelID при старте приложения.</summary>
|
||||
public const string ToastAppUserModelId = "EmbyToolbox.Desktop";
|
||||
|
||||
private readonly LoggingService _logging;
|
||||
private readonly Func<bool> _soundPref;
|
||||
private readonly Func<bool> _toastPref;
|
||||
private readonly Dispatcher? _dispatcher;
|
||||
|
||||
public NotificationService(
|
||||
LoggingService logging,
|
||||
Func<bool> notifyCompletionSoundAfterQueueEnabled,
|
||||
Func<bool> notifyWindowsToastAfterQueueEnabled,
|
||||
Dispatcher? uiDispatcher)
|
||||
{
|
||||
_logging = logging;
|
||||
_soundPref = notifyCompletionSoundAfterQueueEnabled;
|
||||
_toastPref = notifyWindowsToastAfterQueueEnabled;
|
||||
_dispatcher = uiDispatcher;
|
||||
|
||||
LogAppUserModelRegistrationState();
|
||||
}
|
||||
|
||||
private void LogAppUserModelRegistrationState()
|
||||
{
|
||||
if (string.Equals(AppUserModelIdRegistration.LastRegisteredId, ToastAppUserModelId, StringComparison.Ordinal))
|
||||
{
|
||||
_logging.Info($"App User Model ID: {ToastAppUserModelId}", "notify");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AppUserModelIdRegistration.LastDiagnostics))
|
||||
{
|
||||
_logging.Warning(
|
||||
$"Windows toast недоступен: не удалось зарегистрировать AppUserModelID ({AppUserModelIdRegistration.LastDiagnostics})",
|
||||
"notify");
|
||||
return;
|
||||
}
|
||||
|
||||
_logging.Warning("Windows toast недоступен: статус регистрации AppUserModelID неизвестен", "notify");
|
||||
}
|
||||
|
||||
public void NotifyQueueCompleted(int successCount, int errorCount)
|
||||
{
|
||||
successCount = Math.Max(0, successCount);
|
||||
errorCount = Math.Max(0, errorCount);
|
||||
QueueOnUiIdle(
|
||||
() =>
|
||||
{
|
||||
PlayQueueCompletionSound(successCount, errorCount);
|
||||
|
||||
string body;
|
||||
if (errorCount > 0)
|
||||
{
|
||||
body = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
"Обработка завершена с ошибками. Успешно: {0}, ошибок: {1}.",
|
||||
successCount,
|
||||
errorCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
body = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
"Обработка завершена. Файлов обработано: {0}.",
|
||||
successCount);
|
||||
}
|
||||
|
||||
TryShowWindowsToastInternal("Emby Toolbox", body, respectToastPreference: true, contextHint: null);
|
||||
});
|
||||
}
|
||||
|
||||
public void NotifyQueueCancelled()
|
||||
{
|
||||
QueueOnUiIdle(
|
||||
() => TryShowWindowsToastInternal(
|
||||
"Emby Toolbox",
|
||||
"Обработка остановлена пользователем.",
|
||||
respectToastPreference: true,
|
||||
contextHint: null));
|
||||
}
|
||||
|
||||
public void PlayCompletionSound(bool hasErrors)
|
||||
{
|
||||
QueueOnUiIdle(
|
||||
() =>
|
||||
{
|
||||
if (!_soundPref())
|
||||
{
|
||||
_logging.Info("уведомления отключены в настройках (звук)", "notify");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ResolveCompletionSound(hasErrors ? 2 : 0).Play();
|
||||
_logging.Info("звук уведомления о завершении очереди воспроизведён", "notify");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging.Warning($"ошибка воспроизведения звука: {ex.Message}", "notify");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Кнопка «Проверить уведомление»: звук и toast вне зависимости от флагов уведомлений.</summary>
|
||||
public void ShowSettingsTestNotification()
|
||||
{
|
||||
QueueOnUiIdle(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
SystemSounds.Asterisk.Play();
|
||||
_logging.Info("тест: воспроизведён звук Asterisk", "notify");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging.Warning($"ошибка тестового звука: {ex.Message}", "notify");
|
||||
}
|
||||
|
||||
TryShowWindowsToastInternal(
|
||||
"Emby Toolbox",
|
||||
"Тестовое уведомление",
|
||||
respectToastPreference: false,
|
||||
contextHint: "тест из настроек");
|
||||
});
|
||||
}
|
||||
|
||||
private static SystemSound ResolveCompletionSound(int kind) =>
|
||||
kind switch
|
||||
{
|
||||
1 => SystemSounds.Exclamation,
|
||||
>= 2 => SystemSounds.Hand,
|
||||
_ => SystemSounds.Asterisk
|
||||
};
|
||||
|
||||
private void QueueOnUiIdle(Action work)
|
||||
{
|
||||
if (_dispatcher is { HasShutdownStarted: false })
|
||||
{
|
||||
_dispatcher.BeginInvoke(work, DispatcherPriority.ApplicationIdle);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
work();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging.Warning($"уведомление: ошибка без UI dispatcher — {ex.Message}", "notify");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayQueueCompletionSound(int successCount, int errorCount)
|
||||
{
|
||||
if (!_soundPref())
|
||||
{
|
||||
_logging.Info("уведомления отключены в настройках (звук)", "notify");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var kind = errorCount <= 0 ? 0 : successCount > 0 ? 1 : 2;
|
||||
ResolveCompletionSound(kind).Play();
|
||||
_logging.Info("звук уведомления о завершении очереди воспроизведён", "notify");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging.Warning($"ошибка воспроизведения звука: {ex.Message}", "notify");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryShowWindowsToastInternal(string title, string body, bool respectToastPreference, string? contextHint)
|
||||
{
|
||||
if (respectToastPreference && !_toastPref())
|
||||
{
|
||||
_logging.Info("уведомления отключены в настройках", "notify");
|
||||
return;
|
||||
}
|
||||
|
||||
var ctx = string.IsNullOrWhiteSpace(contextHint) ? string.Empty : $" ({contextHint})";
|
||||
_logging.Info($"попытка показать toast: «{EscapeForLog(title)}»{ctx}", "notify");
|
||||
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 10240))
|
||||
{
|
||||
_logging.Warning("Windows toast недоступен: требуется Windows 10 (10240) или новее", "notify");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ShowWindowsToastCore(title, body);
|
||||
_logging.Info("toast успешно отправлен", "notify");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging.Warning($"ошибка toast: {ex.Message}", "notify", ex);
|
||||
_logging.Warning($"Windows toast недоступен: {ex.Message}", "notify");
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeForLog(string s) =>
|
||||
s.Replace("\r\n", " ", StringComparison.Ordinal).Trim();
|
||||
|
||||
[SupportedOSPlatform("windows10.0.10240.0")]
|
||||
private static void ShowWindowsToastCore(string title, string body)
|
||||
{
|
||||
var xmlPayload =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>" +
|
||||
"<toast>" +
|
||||
"<visual><binding template=\"ToastGeneric\">" +
|
||||
"<text id=\"1\">" + EscapeXml(title) + "</text>" +
|
||||
"<text id=\"2\">" + EscapeXml(body) + "</text>" +
|
||||
"</binding></visual>" +
|
||||
"</toast>";
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xmlPayload);
|
||||
|
||||
var toast = new ToastNotification(doc);
|
||||
toast.ExpirationTime = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
|
||||
var notifier = ToastNotificationManager.CreateToastNotifier(ToastAppUserModelId);
|
||||
notifier.Show(toast);
|
||||
}
|
||||
|
||||
private static string EscapeXml(string s)
|
||||
{
|
||||
return s.Replace("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace("\"", """, StringComparison.Ordinal)
|
||||
.Replace("'", "'", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
15
EmbyToolbox/Services/ProfileSettingsProvider.cs
Normal file
15
EmbyToolbox/Services/ProfileSettingsProvider.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class ProfileSettingsProvider : IProfileSettingsProvider
|
||||
{
|
||||
private readonly Func<string, ConversionProfileSettingsEntry?> _resolve;
|
||||
|
||||
public ProfileSettingsProvider(Func<string, ConversionProfileSettingsEntry?> resolve)
|
||||
{
|
||||
_resolve = resolve;
|
||||
}
|
||||
|
||||
public ConversionProfileSettingsEntry? GetProfile(string name) => _resolve(name);
|
||||
}
|
||||
325
EmbyToolbox/Services/QueueAnalysisService.cs
Normal file
325
EmbyToolbox/Services/QueueAnalysisService.cs
Normal file
@ -0,0 +1,325 @@
|
||||
using System.Linq;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Ход пакетного анализа очереди (ffprobe) для IProgress и UI.</summary>
|
||||
public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount);
|
||||
|
||||
/// <summary>
|
||||
/// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по <see cref="CancellationToken"/>,
|
||||
/// обновление строки через <paramref name="uiInvoke"/> (UI).
|
||||
/// </summary>
|
||||
public sealed class QueueAnalysisService
|
||||
{
|
||||
private const int MaxParallel = 2;
|
||||
private readonly FfprobeService _ffprobe;
|
||||
private readonly LoggingService _logging;
|
||||
private readonly SidecarDiscoveryService _sidecar;
|
||||
private readonly ConversionPlanService _planService;
|
||||
private readonly IProfileSettingsProvider _profile;
|
||||
|
||||
public QueueAnalysisService(
|
||||
FfprobeService ffprobe,
|
||||
LoggingService logging,
|
||||
SidecarDiscoveryService sidecar,
|
||||
ConversionPlanService planService,
|
||||
IProfileSettingsProvider profile)
|
||||
{
|
||||
_ffprobe = ffprobe;
|
||||
_logging = logging;
|
||||
_sidecar = sidecar;
|
||||
_planService = planService;
|
||||
_profile = profile;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(
|
||||
IReadOnlyList<ConversionQueueItem> items,
|
||||
Func<ConversionQueueItem, bool> isStillInQueue,
|
||||
bool autoRemoveForeignTracks,
|
||||
bool disableSubtitleDefault,
|
||||
IProgress<QueueAnalysisProgress>? progress,
|
||||
Func<Action, Task> uiInvoke,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = items.Count;
|
||||
var lockObj = new object();
|
||||
var state = new ProgressState();
|
||||
var sem = new SemaphoreSlim(MaxParallel, MaxParallel);
|
||||
var tasks = items
|
||||
.Select(
|
||||
item => ProcessOneAsync(
|
||||
item,
|
||||
isStillInQueue,
|
||||
progress,
|
||||
uiInvoke,
|
||||
autoRemoveForeignTracks,
|
||||
disableSubtitleDefault,
|
||||
sem,
|
||||
lockObj,
|
||||
total,
|
||||
state,
|
||||
cancellationToken))
|
||||
.ToArray();
|
||||
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
return state.ErrorCount;
|
||||
}
|
||||
|
||||
private async Task ProcessOneAsync(
|
||||
ConversionQueueItem item,
|
||||
Func<ConversionQueueItem, bool> isStillInQueue,
|
||||
IProgress<QueueAnalysisProgress>? progress,
|
||||
Func<Action, Task> uiInvoke,
|
||||
bool autoRemoveForeignTracks,
|
||||
bool disableSubtitleDefault,
|
||||
SemaphoreSlim sem,
|
||||
object lockObj,
|
||||
int total,
|
||||
ProgressState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var acquired = false;
|
||||
var itemAnalysisFailed = false;
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await sem.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
acquired = true;
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
FfprobeResult result;
|
||||
try
|
||||
{
|
||||
result = await _ffprobe.AnalyzeAsync(item.FullPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
SidecarDiscoveryResult discovery;
|
||||
try
|
||||
{
|
||||
discovery = await _sidecar.DiscoverAsync(item.FullPath, _ffprobe, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await MarkCancelledOnUiAsync(item, isStillInQueue, uiInvoke).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging.Error($"поиск sidecar: {ex.Message} — {item.FullPath}", "conversion.sidecar", ex);
|
||||
discovery = new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
|
||||
}
|
||||
|
||||
var media = result.IsSuccess ? MediaAnalysisParser.TryParse(result.Json) : null;
|
||||
|
||||
var failed = false;
|
||||
await uiInvoke(
|
||||
() =>
|
||||
{
|
||||
if (!isStillInQueue(item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Status != ConversionQueueStatus.Analyzing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
failed = true;
|
||||
item.PlanSummary = "Ошибка анализа";
|
||||
item.Status = ConversionQueueStatus.Error;
|
||||
item.Progress = 0;
|
||||
item.ErrorMessage =
|
||||
string.IsNullOrWhiteSpace(result.Error) ? "Ошибка ffprobe." : result.Error.Trim();
|
||||
item.ErrorDetails =
|
||||
string.IsNullOrWhiteSpace(result.StdErr) ? null : result.StdErr.Trim();
|
||||
_logging.Error($"ffprobe: {result.Error} — {item.FullPath}", "conversion.ffprobe");
|
||||
return;
|
||||
}
|
||||
|
||||
if (media is null)
|
||||
{
|
||||
failed = true;
|
||||
item.PlanSummary = "Ошибка анализа";
|
||||
item.Status = ConversionQueueStatus.Error;
|
||||
item.Progress = 0;
|
||||
item.ErrorMessage = "Не удалось разобрать ответ ffprobe (неверный JSON).";
|
||||
item.ErrorDetails = !string.IsNullOrWhiteSpace(result.StdErr)
|
||||
? result.StdErr.Trim()
|
||||
: (string.IsNullOrWhiteSpace(result.Json) ? null : result.Json.Trim());
|
||||
_logging.Error($"ffprobe: неверный JSON (media) — {item.FullPath}", "conversion.ffprobe");
|
||||
return;
|
||||
}
|
||||
|
||||
var audio = FfprobeAudioInfoParser.TryParse(result.Json) ?? new FfprobeAudioInfo(0, null, true);
|
||||
var side = discovery.Sidecars;
|
||||
var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||||
// При повторном анализе sidecar-набор может измениться (добавили/удалили внешние файлы).
|
||||
// Пересобираем список дорожек, чтобы не держать устаревшие external entries.
|
||||
item.TaskOverride.TrackOverrides.Clear();
|
||||
TrackOverrideSeeder.EnsureDefaults(
|
||||
item.TaskOverride,
|
||||
media,
|
||||
side,
|
||||
profile,
|
||||
autoRemoveForeignTracks,
|
||||
discovery.ExternalAudioFiles,
|
||||
item.FullPath,
|
||||
sidecarTitleResolver: null,
|
||||
logging: _logging,
|
||||
disableSubtitleDefault: disableSubtitleDefault);
|
||||
if (autoRemoveForeignTracks)
|
||||
{
|
||||
var removedForeign = item.TaskOverride.TrackOverrides.Count(t =>
|
||||
t.Source == SourceKind.Embedded
|
||||
&& t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle
|
||||
&& t.Action == TrackActionKind.Remove
|
||||
&& IsForeignLanguageForAutoRemove(t.Language));
|
||||
if (removedForeign > 0)
|
||||
{
|
||||
_logging.Info($"автоудаление иностранных дорожек: {removedForeign} ({item.FullPath})", "conversion.queue");
|
||||
}
|
||||
}
|
||||
|
||||
var plan = _planService.Build(media, side, profile, item.TaskOverride, discovery.ExternalAudioFiles);
|
||||
item.SetSuccessfulMediaAnalysis(
|
||||
media,
|
||||
side,
|
||||
discovery.ExternalAudioFiles,
|
||||
plan,
|
||||
audio.AudioStreamCount,
|
||||
audio.AudioSizeMbTotal,
|
||||
audio.IsPartial);
|
||||
item.Status = ConversionQueueStatus.Pending;
|
||||
item.Progress = 0;
|
||||
})
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (failed)
|
||||
{
|
||||
itemAnalysisFailed = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
itemAnalysisFailed = true;
|
||||
_logging.Error($"ffprobe очереди: {ex.Message}", "conversion.ffprobe", ex);
|
||||
await uiInvoke(
|
||||
() =>
|
||||
{
|
||||
if (!isStillInQueue(item) || item.Status != ConversionQueueStatus.Analyzing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
item.PlanSummary = "Ошибка анализа";
|
||||
item.Status = ConversionQueueStatus.Error;
|
||||
item.Progress = 0;
|
||||
item.ErrorMessage = ex.Message;
|
||||
item.ErrorDetails = null;
|
||||
})
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
int p;
|
||||
int e;
|
||||
lock (lockObj)
|
||||
{
|
||||
state.ProcessedCount++;
|
||||
if (itemAnalysisFailed)
|
||||
{
|
||||
state.ErrorCount++;
|
||||
}
|
||||
|
||||
p = state.ProcessedCount;
|
||||
e = state.ErrorCount;
|
||||
}
|
||||
|
||||
progress?.Report(new QueueAnalysisProgress(p, total, e));
|
||||
if (acquired)
|
||||
{
|
||||
sem.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MarkCancelledOnUiAsync(
|
||||
ConversionQueueItem item,
|
||||
Func<ConversionQueueItem, bool> isStillInQueue,
|
||||
Func<Action, Task> uiInvoke)
|
||||
{
|
||||
await uiInvoke(
|
||||
() =>
|
||||
{
|
||||
if (!isStillInQueue(item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Status != ConversionQueueStatus.Analyzing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
item.PlanSummary = "Анализ отменён";
|
||||
item.Status = ConversionQueueStatus.Cancelled;
|
||||
item.Progress = 0;
|
||||
})
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class ProgressState
|
||||
{
|
||||
public int ProcessedCount;
|
||||
public int ErrorCount;
|
||||
}
|
||||
|
||||
private static bool IsForeignLanguageForAutoRemove(string? language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lang = language.Trim().ToLowerInvariant();
|
||||
if (lang is "und" or "unknown" or "?")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return lang is not ("rus" or "ru");
|
||||
}
|
||||
}
|
||||
248
EmbyToolbox/Services/RecentPathService.cs
Normal file
248
EmbyToolbox/Services/RecentPathService.cs
Normal file
@ -0,0 +1,248 @@
|
||||
using System.IO;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сценарии для запоминания последних каталогов в диалогах открытия/сохранения.
|
||||
/// </summary>
|
||||
public enum RecentPathScenario
|
||||
{
|
||||
SeriesRenamer,
|
||||
ConversionAddFiles,
|
||||
ConversionAddFolder,
|
||||
Merge,
|
||||
VideoInfoOpenFile,
|
||||
SettingsTempFolder,
|
||||
SettingsOutputFolder,
|
||||
TrackExtractDestination
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запоминает последние каталоги по сценариям и общий каталог; сохраняет вместе с <see cref="AppSettings"/>.
|
||||
/// </summary>
|
||||
public sealed class RecentPathService
|
||||
{
|
||||
private readonly AppSettingsService _settingsService;
|
||||
|
||||
private string? _lastSeriesRenamerFolder;
|
||||
private string? _lastConversionFilesFolder;
|
||||
private string? _lastConversionFolder;
|
||||
private string? _lastMergeFolder;
|
||||
private string? _lastVideoInfoFolder;
|
||||
private string? _lastTempFolder;
|
||||
private string? _lastOutputFolder;
|
||||
private string? _lastTrackExtractDestinationFolder;
|
||||
private string? _lastCommonFolder;
|
||||
|
||||
public RecentPathService(AppSettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public void HydrateFrom(AppSettings settings)
|
||||
{
|
||||
_lastSeriesRenamerFolder = settings.LastSeriesRenamerFolder;
|
||||
_lastConversionFilesFolder = settings.LastConversionFilesFolder;
|
||||
_lastConversionFolder = settings.LastConversionFolder;
|
||||
_lastMergeFolder = settings.LastMergeFolder;
|
||||
_lastVideoInfoFolder = settings.LastVideoInfoFolder;
|
||||
_lastTempFolder = settings.LastTempFolder;
|
||||
_lastOutputFolder = settings.LastOutputFolder;
|
||||
_lastTrackExtractDestinationFolder = settings.LastTrackExtractDestinationFolder;
|
||||
_lastCommonFolder = settings.LastCommonFolder;
|
||||
}
|
||||
|
||||
public void ApplyTo(AppSettings target)
|
||||
{
|
||||
target.LastSeriesRenamerFolder = _lastSeriesRenamerFolder;
|
||||
target.LastConversionFilesFolder = _lastConversionFilesFolder;
|
||||
target.LastConversionFolder = _lastConversionFolder;
|
||||
target.LastMergeFolder = _lastMergeFolder;
|
||||
target.LastVideoInfoFolder = _lastVideoInfoFolder;
|
||||
target.LastTempFolder = _lastTempFolder;
|
||||
target.LastOutputFolder = _lastOutputFolder;
|
||||
target.LastTrackExtractDestinationFolder = _lastTrackExtractDestinationFolder;
|
||||
target.LastCommonFolder = _lastCommonFolder;
|
||||
}
|
||||
|
||||
/// <param name="extraFolderFallbackBeforeDefault">
|
||||
/// Дополнительный существующий каталог перед стандартным «Видео» (для TEMP: текущий выбранный каталог из настроек).
|
||||
/// </param>
|
||||
public string GetInitialDirectory(RecentPathScenario scenario, string? extraFolderFallbackBeforeDefault = null)
|
||||
{
|
||||
foreach (var candidate in GetCandidateChain(scenario, extraFolderFallbackBeforeDefault))
|
||||
{
|
||||
if (IsUsableExistingDirectory(candidate))
|
||||
{
|
||||
return candidate!;
|
||||
}
|
||||
}
|
||||
|
||||
return Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
|
||||
}
|
||||
|
||||
public void RememberChosenFiles(RecentPathScenario scenario, IReadOnlyList<string> chosenFilePaths)
|
||||
{
|
||||
if (chosenFilePaths.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dir = NormalizeExistingDirectory(Path.GetDirectoryName(chosenFilePaths[0]));
|
||||
if (dir is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetScenarioFolder(scenario, dir);
|
||||
_lastCommonFolder = dir;
|
||||
Persist();
|
||||
}
|
||||
|
||||
public string? GetNormalizedRememberedFolderPath(RecentPathScenario scenario)
|
||||
{
|
||||
var folder = GetScenarioStored(scenario);
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Path.GetFullPath(folder.Trim());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void RememberChosenFolder(RecentPathScenario scenario, string folderPathOrFile)
|
||||
{
|
||||
var dir = NormalizeExistingDirectory(folderPathOrFile);
|
||||
if (dir is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetScenarioFolder(scenario, dir);
|
||||
_lastCommonFolder = dir;
|
||||
Persist();
|
||||
}
|
||||
|
||||
private IEnumerable<string?> GetCandidateChain(RecentPathScenario scenario, string? extraFolderFallbackBeforeDefault)
|
||||
{
|
||||
yield return GetScenarioStored(scenario);
|
||||
yield return _lastCommonFolder;
|
||||
if (!string.IsNullOrWhiteSpace(extraFolderFallbackBeforeDefault))
|
||||
{
|
||||
yield return NormalizePathOnly(extraFolderFallbackBeforeDefault);
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetScenarioStored(RecentPathScenario scenario) =>
|
||||
scenario switch
|
||||
{
|
||||
RecentPathScenario.SeriesRenamer => _lastSeriesRenamerFolder,
|
||||
RecentPathScenario.ConversionAddFiles => _lastConversionFilesFolder,
|
||||
RecentPathScenario.ConversionAddFolder => _lastConversionFolder,
|
||||
RecentPathScenario.Merge => _lastMergeFolder,
|
||||
RecentPathScenario.VideoInfoOpenFile => _lastVideoInfoFolder,
|
||||
RecentPathScenario.SettingsTempFolder => _lastTempFolder,
|
||||
RecentPathScenario.SettingsOutputFolder => _lastOutputFolder,
|
||||
RecentPathScenario.TrackExtractDestination => _lastTrackExtractDestinationFolder,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private void SetScenarioFolder(RecentPathScenario scenario, string normalizedDirectory)
|
||||
{
|
||||
switch (scenario)
|
||||
{
|
||||
case RecentPathScenario.SeriesRenamer:
|
||||
_lastSeriesRenamerFolder = normalizedDirectory;
|
||||
break;
|
||||
case RecentPathScenario.ConversionAddFiles:
|
||||
_lastConversionFilesFolder = normalizedDirectory;
|
||||
break;
|
||||
case RecentPathScenario.ConversionAddFolder:
|
||||
_lastConversionFolder = normalizedDirectory;
|
||||
break;
|
||||
case RecentPathScenario.Merge:
|
||||
_lastMergeFolder = normalizedDirectory;
|
||||
break;
|
||||
case RecentPathScenario.VideoInfoOpenFile:
|
||||
_lastVideoInfoFolder = normalizedDirectory;
|
||||
break;
|
||||
case RecentPathScenario.SettingsTempFolder:
|
||||
_lastTempFolder = normalizedDirectory;
|
||||
break;
|
||||
case RecentPathScenario.SettingsOutputFolder:
|
||||
_lastOutputFolder = normalizedDirectory;
|
||||
break;
|
||||
case RecentPathScenario.TrackExtractDestination:
|
||||
_lastTrackExtractDestinationFolder = normalizedDirectory;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
var disk = _settingsService.Load();
|
||||
ApplyTo(disk);
|
||||
_settingsService.Save(disk);
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(path.Trim());
|
||||
return Directory.Exists(full) ? full : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizePathOnly(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Path.GetFullPath(path.Trim());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUsableExistingDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Directory.Exists(Path.GetFullPath(path.Trim()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
EmbyToolbox/Services/SafeFileReplaceService.cs
Normal file
129
EmbyToolbox/Services/SafeFileReplaceService.cs
Normal file
@ -0,0 +1,129 @@
|
||||
using System.IO;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class SafeFileReplaceService
|
||||
{
|
||||
public async Task ReplaceAsync(
|
||||
string originalPath,
|
||||
string finalPath,
|
||||
string tempOutputPath,
|
||||
IProgress<int>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(tempOutputPath))
|
||||
{
|
||||
throw new IOException("Временный файл результата не найден.");
|
||||
}
|
||||
|
||||
var tempInfo = new FileInfo(tempOutputPath);
|
||||
if (tempInfo.Length <= 0)
|
||||
{
|
||||
throw new IOException("Временный файл результата пуст.");
|
||||
}
|
||||
|
||||
var backupPath = originalPath + ".bak_replace_tmp";
|
||||
var finalBackupPath = finalPath + ".bak_existing_tmp";
|
||||
if (File.Exists(backupPath))
|
||||
{
|
||||
File.Delete(backupPath);
|
||||
}
|
||||
|
||||
if (File.Exists(finalBackupPath))
|
||||
{
|
||||
File.Delete(finalBackupPath);
|
||||
}
|
||||
|
||||
File.Move(originalPath, backupPath, overwrite: false);
|
||||
try
|
||||
{
|
||||
if (!string.Equals(originalPath, finalPath, StringComparison.OrdinalIgnoreCase) && File.Exists(finalPath))
|
||||
{
|
||||
File.Move(finalPath, finalBackupPath, overwrite: false);
|
||||
}
|
||||
|
||||
var sameRoot = string.Equals(Path.GetPathRoot(finalPath), Path.GetPathRoot(tempOutputPath), StringComparison.OrdinalIgnoreCase);
|
||||
if (sameRoot)
|
||||
{
|
||||
File.Move(tempOutputPath, finalPath, overwrite: true);
|
||||
progress?.Report(99);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CopyWithProgressAsync(tempOutputPath, finalPath, progress, cancellationToken).ConfigureAwait(false);
|
||||
File.Delete(tempOutputPath);
|
||||
}
|
||||
|
||||
var outInfo = new FileInfo(finalPath);
|
||||
if (!outInfo.Exists || outInfo.Length <= 0)
|
||||
{
|
||||
throw new IOException("Новый файл после замены невалиден.");
|
||||
}
|
||||
|
||||
File.Delete(backupPath);
|
||||
if (File.Exists(finalBackupPath))
|
||||
{
|
||||
File.Delete(finalBackupPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(finalPath))
|
||||
{
|
||||
var i = new FileInfo(finalPath);
|
||||
if (i.Length == 0)
|
||||
{
|
||||
File.Delete(finalPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (File.Exists(backupPath))
|
||||
{
|
||||
File.Move(backupPath, originalPath, overwrite: true);
|
||||
}
|
||||
|
||||
if (File.Exists(finalBackupPath))
|
||||
{
|
||||
File.Move(finalBackupPath, finalPath, overwrite: true);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CopyWithProgressAsync(string src, string dst, IProgress<int>? progress, CancellationToken token)
|
||||
{
|
||||
const int bufferSize = 1024 * 1024;
|
||||
await using var input = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
|
||||
await using var output = new FileStream(dst, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, useAsync: true);
|
||||
var total = input.Length;
|
||||
var copied = 0L;
|
||||
var buffer = new byte[bufferSize];
|
||||
while (true)
|
||||
{
|
||||
var read = await input.ReadAsync(buffer, token).ConfigureAwait(false);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), token).ConfigureAwait(false);
|
||||
copied += read;
|
||||
if (total > 0)
|
||||
{
|
||||
var frac = copied / (double)total;
|
||||
var extra = frac >= 1.0 ? 9 : (int)Math.Floor(frac * 10.0);
|
||||
extra = Math.Clamp(extra, 0, 9);
|
||||
progress?.Report(90 + extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
468
EmbyToolbox/Services/SeriesRenamerService.cs
Normal file
468
EmbyToolbox/Services/SeriesRenamerService.cs
Normal file
@ -0,0 +1,468 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.IO;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class SeriesRenamerService
|
||||
{
|
||||
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mkv",".mp4",".avi",".mov",".wmv",".flv",".ts",".m2ts",".webm",".mpeg",".mpg",".m4v",".3gp",".ogv",".vob",".rmvb",".asf",".divx",".f4v",".mts",".m2v",".mp2",".mpv",".qt",".hevc",".h265",".h264"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SidecarExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".srt",".ass",".ssa",".sub",".idx",".sup",".vtt",".txt",".smi",".rt",
|
||||
".aac",".ac3",".eac3",".dts",".flac",".m4a",".mp3",".opus",".wav",".oga",
|
||||
".jpg",".jpeg",".png",".webp",".nfo",".xml",".cue",".log",".url"
|
||||
};
|
||||
|
||||
public SeriesRenamePreview BuildPreview(string? rootPath, string? newSeriesName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return SeriesRenamePreview.Unsupported("Папка сериала не выбрана.");
|
||||
}
|
||||
|
||||
var seriesName = (newSeriesName ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(seriesName))
|
||||
{
|
||||
return SeriesRenamePreview.Unsupported("Пустое имя сериала.");
|
||||
}
|
||||
|
||||
if (seriesName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
{
|
||||
return SeriesRenamePreview.Unsupported("Имя сериала содержит недопустимые символы.");
|
||||
}
|
||||
|
||||
var rootInfo = new DirectoryInfo(rootPath);
|
||||
var seasonDirs = rootInfo.GetDirectories().OrderBy(d => d.Name, NaturalStringComparer.Instance).ToList();
|
||||
if (seasonDirs.Count == 0)
|
||||
{
|
||||
return SeriesRenamePreview.Unsupported("Нет папок сезонов.");
|
||||
}
|
||||
|
||||
foreach (var season in seasonDirs)
|
||||
{
|
||||
if (season.GetDirectories().Length > 0)
|
||||
{
|
||||
return SeriesRenamePreview.Unsupported($"В сезоне '{season.Name}' обнаружены вложенные папки.");
|
||||
}
|
||||
}
|
||||
|
||||
var seasonNumbers = ResolveSeasonNumbers(seasonDirs);
|
||||
var operations = new List<SeriesRenameOperation>();
|
||||
var rootTargetPath = Path.Combine(rootInfo.Parent?.FullName ?? rootInfo.FullName, seriesName);
|
||||
|
||||
var currentRoot = new SeriesNode("root", rootInfo.Name, "Folder");
|
||||
var previewRoot = new SeriesNode("root", seriesName, "Folder");
|
||||
|
||||
if (!PathsEqual(rootInfo.FullName, rootTargetPath))
|
||||
{
|
||||
operations.Add(new SeriesRenameOperation(OperationKind.RootDirectory, rootInfo.FullName, rootTargetPath));
|
||||
}
|
||||
|
||||
for (var i = 0; i < seasonDirs.Count; i++)
|
||||
{
|
||||
var season = seasonDirs[i];
|
||||
var seasonNo = seasonNumbers[i];
|
||||
var seasonTargetName = $"Season {seasonNo:00}";
|
||||
var seasonTargetPath = Path.Combine(rootInfo.FullName, seasonTargetName);
|
||||
var seasonKey = $"season:{i}:{season.Name}";
|
||||
|
||||
var currentSeasonNode = new SeriesNode(seasonKey, season.Name, "Folder");
|
||||
var previewSeasonNode = new SeriesNode(seasonKey, seasonTargetName, "Folder");
|
||||
currentRoot.Children.Add(currentSeasonNode);
|
||||
previewRoot.Children.Add(previewSeasonNode);
|
||||
|
||||
if (!PathsEqual(season.FullName, seasonTargetPath))
|
||||
{
|
||||
operations.Add(new SeriesRenameOperation(OperationKind.SeasonDirectory, season.FullName, seasonTargetPath));
|
||||
}
|
||||
|
||||
var allFiles = season.GetFiles().OrderBy(f => f.Name, NaturalStringComparer.Instance).ToList();
|
||||
var videoFiles = allFiles.Where(IsVideoFile).OrderBy(f => f.Name, NaturalStringComparer.Instance).ToList();
|
||||
var plannedSidecars = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var e = 0; e < videoFiles.Count; e++)
|
||||
{
|
||||
var video = videoFiles[e];
|
||||
var newStem = $"{seriesName} S{seasonNo:00}E{e + 1:00}";
|
||||
var newVideoName = $"{newStem}{video.Extension}";
|
||||
var newVideoPath = Path.Combine(video.DirectoryName!, newVideoName);
|
||||
var videoKey = $"{seasonKey}/video:{e}:{video.Name}";
|
||||
|
||||
currentSeasonNode.Children.Add(new SeriesNode(videoKey, video.Name, "Video"));
|
||||
previewSeasonNode.Children.Add(new SeriesNode(videoKey, newVideoName, "Video"));
|
||||
|
||||
if (!PathsEqual(video.FullName, newVideoPath))
|
||||
{
|
||||
operations.Add(new SeriesRenameOperation(OperationKind.File, video.FullName, newVideoPath));
|
||||
}
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(video.Name);
|
||||
var linked = allFiles
|
||||
.Where(f => !PathsEqual(f.FullName, video.FullName))
|
||||
.Where(f => f.Name.StartsWith(stem + ".", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(IsSidecarFile)
|
||||
.ToList();
|
||||
|
||||
foreach (var sidecar in linked)
|
||||
{
|
||||
if (!plannedSidecars.Add(sidecar.FullName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var suffix = sidecar.Name[stem.Length..];
|
||||
var newSidecarName = $"{newStem}{suffix}";
|
||||
var newSidecarPath = Path.Combine(sidecar.DirectoryName!, newSidecarName);
|
||||
var sidecarKey = $"{seasonKey}/sidecar:{sidecar.Name}";
|
||||
|
||||
currentSeasonNode.Children.Add(new SeriesNode(sidecarKey, sidecar.Name, "Sidecar"));
|
||||
previewSeasonNode.Children.Add(new SeriesNode(sidecarKey, newSidecarName, "Sidecar"));
|
||||
|
||||
if (!PathsEqual(sidecar.FullName, newSidecarPath))
|
||||
{
|
||||
operations.Add(new SeriesRenameOperation(OperationKind.File, sidecar.FullName, newSidecarPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var deduped = operations
|
||||
.GroupBy(o => o.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.Last())
|
||||
.ToList();
|
||||
|
||||
return SeriesRenamePreview.Supported(currentRoot, previewRoot, deduped);
|
||||
}
|
||||
|
||||
public SeriesRenameExecutionResult ExecutePreview(SeriesRenamePreview preview, string currentRootPath)
|
||||
{
|
||||
if (!preview.IsSupported)
|
||||
{
|
||||
return SeriesRenameExecutionResult.Fail(preview.UnsupportedReason ?? "Предпросмотр недоступен.", currentRootPath, currentRootPath, false);
|
||||
}
|
||||
|
||||
var fileOps = preview.Operations.Where(o => o.Kind == OperationKind.File && !PathsEqual(o.SourcePath, o.TargetPath)).ToList();
|
||||
var seasonOps = preview.Operations.Where(o => o.Kind == OperationKind.SeasonDirectory && !PathsEqual(o.SourcePath, o.TargetPath)).ToList();
|
||||
var rootOp = preview.Operations.FirstOrDefault(o => o.Kind == OperationKind.RootDirectory && !PathsEqual(o.SourcePath, o.TargetPath));
|
||||
var oldRootPath = currentRootPath;
|
||||
var newRootPath = currentRootPath;
|
||||
var rootWasRenamed = false;
|
||||
|
||||
var rollbackActions = new Stack<(string from, string to, bool isDir)>();
|
||||
|
||||
try
|
||||
{
|
||||
ExecuteFileOps(fileOps, rollbackActions);
|
||||
ExecuteSeasonOps(seasonOps, rollbackActions);
|
||||
if (rootOp is not null)
|
||||
{
|
||||
newRootPath = ExecuteRootOp(rootOp, rollbackActions);
|
||||
rootWasRenamed = !PathsEqual(oldRootPath, newRootPath);
|
||||
}
|
||||
|
||||
return SeriesRenameExecutionResult.Ok(oldRootPath, newRootPath, rootWasRenamed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
while (rollbackActions.Count > 0)
|
||||
{
|
||||
var action = rollbackActions.Pop();
|
||||
try
|
||||
{
|
||||
MovePath(action.from, action.to, action.isDir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort rollback.
|
||||
}
|
||||
}
|
||||
|
||||
return SeriesRenameExecutionResult.Fail(ex.Message, oldRootPath, newRootPath, rootWasRenamed);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExecuteFileOps(List<SeriesRenameOperation> fileOps, Stack<(string from, string to, bool isDir)> rollbackActions)
|
||||
{
|
||||
if (fileOps.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedTargets = ResolveTargets(fileOps, isDirectory: false);
|
||||
var staged = new List<(string source, string temp, string final)>();
|
||||
var finalized = new List<(string source, string temp, string final)>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var op in fileOps)
|
||||
{
|
||||
var temp = GetTempPath(op.SourcePath);
|
||||
MovePath(op.SourcePath, temp, isDirectory: false);
|
||||
staged.Add((op.SourcePath, temp, resolvedTargets[op.SourcePath]));
|
||||
}
|
||||
|
||||
foreach (var item in staged)
|
||||
{
|
||||
MovePath(item.temp, item.final, isDirectory: false);
|
||||
finalized.Add(item);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
for (var i = finalized.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var item = finalized[i];
|
||||
if (File.Exists(item.final))
|
||||
{
|
||||
MovePath(item.final, item.temp, isDirectory: false);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = staged.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var item = staged[i];
|
||||
if (File.Exists(item.temp))
|
||||
{
|
||||
MovePath(item.temp, item.source, isDirectory: false);
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
foreach (var item in finalized)
|
||||
{
|
||||
rollbackActions.Push((item.final, item.source, false));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExecuteSeasonOps(List<SeriesRenameOperation> seasonOps, Stack<(string from, string to, bool isDir)> rollbackActions)
|
||||
{
|
||||
if (seasonOps.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = ResolveTargets(seasonOps, isDirectory: true);
|
||||
var staged = new List<(string source, string temp, string final)>();
|
||||
var finalized = new List<(string source, string temp, string final)>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var op in seasonOps.OrderByDescending(o => o.SourcePath.Length))
|
||||
{
|
||||
var temp = GetTempPath(op.SourcePath);
|
||||
MovePath(op.SourcePath, temp, isDirectory: true);
|
||||
staged.Add((op.SourcePath, temp, resolved[op.SourcePath]));
|
||||
}
|
||||
|
||||
foreach (var item in staged)
|
||||
{
|
||||
MovePath(item.temp, item.final, isDirectory: true);
|
||||
finalized.Add(item);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
for (var i = finalized.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var item = finalized[i];
|
||||
if (Directory.Exists(item.final))
|
||||
{
|
||||
MovePath(item.final, item.temp, isDirectory: true);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = staged.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var item = staged[i];
|
||||
if (Directory.Exists(item.temp))
|
||||
{
|
||||
MovePath(item.temp, item.source, isDirectory: true);
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
foreach (var item in finalized)
|
||||
{
|
||||
rollbackActions.Push((item.final, item.source, true));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExecuteRootOp(SeriesRenameOperation rootOp, Stack<(string from, string to, bool isDir)> rollbackActions)
|
||||
{
|
||||
var resolved = ResolveTargets(new List<SeriesRenameOperation> { rootOp }, isDirectory: true);
|
||||
var final = resolved[rootOp.SourcePath];
|
||||
MovePath(rootOp.SourcePath, final, isDirectory: true);
|
||||
rollbackActions.Push((final, rootOp.SourcePath, true));
|
||||
return final;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ResolveTargets(List<SeriesRenameOperation> ops, bool isDirectory)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var sourceSet = ops.Select(o => o.SourcePath).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var reserved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var op in ops)
|
||||
{
|
||||
var candidate = op.TargetPath;
|
||||
var duplicate = 1;
|
||||
while (reserved.Contains(candidate) || (PathExists(candidate, isDirectory) && !sourceSet.Contains(candidate)))
|
||||
{
|
||||
candidate = AppendDuplicate(op.TargetPath, duplicate++, isDirectory);
|
||||
}
|
||||
|
||||
reserved.Add(candidate);
|
||||
result[op.SourcePath] = candidate;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool PathExists(string path, bool isDirectory)
|
||||
{
|
||||
return isDirectory ? Directory.Exists(path) : File.Exists(path);
|
||||
}
|
||||
|
||||
private static string AppendDuplicate(string path, int duplicate, bool isDirectory)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path) ?? string.Empty;
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
var ext = isDirectory ? string.Empty : Path.GetExtension(path);
|
||||
return Path.Combine(dir, $"{name}_duplicate_{duplicate}{ext}");
|
||||
}
|
||||
|
||||
private static void MovePath(string from, string to, bool isDirectory)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(to);
|
||||
if (!string.IsNullOrWhiteSpace(parent))
|
||||
{
|
||||
Directory.CreateDirectory(parent);
|
||||
}
|
||||
|
||||
if (isDirectory)
|
||||
{
|
||||
Directory.Move(from, to);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetTempPath(string sourcePath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(sourcePath) ?? string.Empty;
|
||||
var name = Path.GetFileName(sourcePath);
|
||||
return Path.Combine(dir, $"{name}.__emby_tmp_{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
private static bool IsVideoFile(FileInfo file)
|
||||
{
|
||||
return VideoExtensions.Contains(file.Extension);
|
||||
}
|
||||
|
||||
private static bool IsSidecarFile(FileInfo file)
|
||||
{
|
||||
return SidecarExtensions.Contains(file.Extension);
|
||||
}
|
||||
|
||||
private static List<int> ResolveSeasonNumbers(List<DirectoryInfo> seasonDirs)
|
||||
{
|
||||
var numeric = seasonDirs
|
||||
.Select(d => TryParseSeasonNumber(d.Name))
|
||||
.ToList();
|
||||
|
||||
if (numeric.All(v => v is not null))
|
||||
{
|
||||
return numeric.Select(v => v!.Value).ToList();
|
||||
}
|
||||
|
||||
return Enumerable.Range(1, seasonDirs.Count).ToList();
|
||||
}
|
||||
|
||||
private static int? TryParseSeasonNumber(string name)
|
||||
{
|
||||
var trimmed = name.Trim();
|
||||
if (Regex.IsMatch(trimmed, @"^\d+$") && int.TryParse(trimmed, out var n))
|
||||
{
|
||||
return n;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string a, string b)
|
||||
{
|
||||
return string.Equals(
|
||||
Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar),
|
||||
Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SeriesRenamePreview
|
||||
{
|
||||
private SeriesRenamePreview(bool isSupported, string? unsupportedReason, SeriesNode? currentTree, SeriesNode? previewTree, IReadOnlyList<SeriesRenameOperation> operations)
|
||||
{
|
||||
IsSupported = isSupported;
|
||||
UnsupportedReason = unsupportedReason;
|
||||
CurrentTree = currentTree;
|
||||
PreviewTree = previewTree;
|
||||
Operations = operations;
|
||||
}
|
||||
|
||||
public bool IsSupported { get; }
|
||||
public string? UnsupportedReason { get; }
|
||||
public SeriesNode? CurrentTree { get; }
|
||||
public SeriesNode? PreviewTree { get; }
|
||||
public IReadOnlyList<SeriesRenameOperation> Operations { get; }
|
||||
|
||||
public static SeriesRenamePreview Unsupported(string reason) => new(false, reason, null, null, Array.Empty<SeriesRenameOperation>());
|
||||
public static SeriesRenamePreview Supported(SeriesNode current, SeriesNode preview, IReadOnlyList<SeriesRenameOperation> operations) => new(true, null, current, preview, operations);
|
||||
}
|
||||
|
||||
public sealed record SeriesNode(string NodeKey, string Name, string Kind)
|
||||
{
|
||||
public List<SeriesNode> Children { get; } = new();
|
||||
}
|
||||
|
||||
public sealed record SeriesRenameOperation(OperationKind Kind, string SourcePath, string TargetPath);
|
||||
|
||||
public enum OperationKind
|
||||
{
|
||||
File,
|
||||
SeasonDirectory,
|
||||
RootDirectory
|
||||
}
|
||||
|
||||
public sealed class SeriesRenameExecutionResult
|
||||
{
|
||||
private SeriesRenameExecutionResult(bool isSuccess, string error, string oldRootPath, string newRootPath, bool rootWasRenamed)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
OldRootPath = oldRootPath;
|
||||
NewRootPath = newRootPath;
|
||||
RootWasRenamed = rootWasRenamed;
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
public string Error { get; }
|
||||
public string OldRootPath { get; }
|
||||
public string NewRootPath { get; }
|
||||
public bool RootWasRenamed { get; }
|
||||
|
||||
public static SeriesRenameExecutionResult Ok(string oldRootPath, string newRootPath, bool rootWasRenamed) =>
|
||||
new(true, string.Empty, oldRootPath, newRootPath, rootWasRenamed);
|
||||
|
||||
public static SeriesRenameExecutionResult Fail(string error, string oldRootPath, string newRootPath, bool rootWasRenamed) =>
|
||||
new(false, error, oldRootPath, newRootPath, rootWasRenamed);
|
||||
}
|
||||
221
EmbyToolbox/Services/SidecarDiscoveryService.cs
Normal file
221
EmbyToolbox/Services/SidecarDiscoveryService.cs
Normal file
@ -0,0 +1,221 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Поиск внешних аудио и субтитров рядом с видеофайлом; для контейнеров с несколькими аудиопотоками — ffprobe.</summary>
|
||||
public sealed class SidecarDiscoveryService
|
||||
{
|
||||
private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private static readonly HashSet<string> AudioExts = new(IC)
|
||||
{
|
||||
".mka", ".mkv", ".mp4", ".m4a",
|
||||
".ac3", ".eac3", ".aac", ".dts", ".flac", ".wav", ".opus", ".ogg", ".mp3", ".wma", ".aiff", ".aif", ".m4b", ".m4r"
|
||||
};
|
||||
|
||||
/// <summary>Расширения: внутри файла может быть несколько аудиопотоков → полный ffprobe.</summary>
|
||||
private static readonly HashSet<string> MultiStreamAudioProbeExts = new(IC)
|
||||
{
|
||||
".mka", ".mkv", ".mp4", ".m4a"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SubExts = new(IC)
|
||||
{
|
||||
".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup", ".smi"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> FontExts = new(IC)
|
||||
{
|
||||
".ttf", ".otf", ".ttc", ".otc"
|
||||
};
|
||||
|
||||
private readonly LoggingService? _logging;
|
||||
|
||||
public SidecarDiscoveryService(LoggingService? logging = null) => _logging = logging;
|
||||
|
||||
public async Task<SidecarDiscoveryResult> DiscoverAsync(
|
||||
string videoPath,
|
||||
FfprobeService ffprobe,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
|
||||
{
|
||||
return new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(videoPath);
|
||||
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
|
||||
{
|
||||
return new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
|
||||
}
|
||||
|
||||
var baseName = Path.GetFileNameWithoutExtension(videoPath);
|
||||
var full = Path.GetFullPath(videoPath);
|
||||
var list = new List<SidecarFile>();
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(dir))
|
||||
{
|
||||
if (string.Equals(path, full, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nameNoExt = Path.GetFileNameWithoutExtension(path);
|
||||
if (!string.Equals(nameNoExt, baseName, StringComparison.OrdinalIgnoreCase)
|
||||
&& !nameNoExt.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(path);
|
||||
if (AudioExts.Contains(ext))
|
||||
{
|
||||
list.Add(new SidecarFile(path, isAudio: true, isSubtitle: false));
|
||||
}
|
||||
else if (SubExts.Contains(ext))
|
||||
{
|
||||
list.Add(new SidecarFile(path, isAudio: false, isSubtitle: true));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var subDir in Directory.EnumerateDirectories(dir))
|
||||
{
|
||||
var name = Path.GetFileName(subDir);
|
||||
if (!name.Equals("font", StringComparison.OrdinalIgnoreCase)
|
||||
&& !name.Equals("fonts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string> fontFiles;
|
||||
try
|
||||
{
|
||||
fontFiles = Directory.EnumerateFiles(subDir, "*.*", SearchOption.AllDirectories);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var f in fontFiles)
|
||||
{
|
||||
if (!FontExts.Contains(Path.GetExtension(f)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new SidecarFile(f, isAudio: false, isSubtitle: false, isFont: true));
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = list.OrderBy(s => s.FileName, IC).ToList();
|
||||
|
||||
var externalAudioByPath = new Dictionary<string, ExternalAudioFile>(IC);
|
||||
var audioPaths = sorted.Where(s => s.IsAudio).Select(s => s.FullPath).Distinct(IC).OrderBy(Path.GetFileName, IC).ToList();
|
||||
|
||||
foreach (var audioPath in audioPaths)
|
||||
{
|
||||
var streams = MultiStreamAudioProbeExts.Contains(Path.GetExtension(audioPath))
|
||||
? await ProbeExternalAudioStreamsAsync(ffprobe, audioPath, cancellationToken).ConfigureAwait(false)
|
||||
: CreateSingleFallbackStream(audioPath);
|
||||
externalAudioByPath[audioPath] = new ExternalAudioFile(audioPath, streams);
|
||||
}
|
||||
|
||||
var externalsOrdered = audioPaths.Select(p => externalAudioByPath[p]).ToList();
|
||||
|
||||
return new SidecarDiscoveryResult(sorted, externalsOrdered);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ExternalAudioStream>> ProbeExternalAudioStreamsAsync(
|
||||
FfprobeService ffprobe,
|
||||
string audioPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ffprobe.AnalyzeAsync(audioPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess || string.IsNullOrWhiteSpace(result.Json))
|
||||
{
|
||||
_logging?.Warning($"ffprobe sidecar аудио: {result.Error ?? "нет данных"} — {audioPath}", "conversion.sidecar");
|
||||
return CreateSingleFallbackStream(audioPath);
|
||||
}
|
||||
|
||||
var media = MediaAnalysisParser.TryParse(result.Json);
|
||||
var audios = media?.AudioStreams?.OrderBy(a => a.Index).ToList();
|
||||
if (audios is not { Count: > 0 })
|
||||
{
|
||||
return CreateSingleFallbackStream(audioPath);
|
||||
}
|
||||
|
||||
var ordinal = 0;
|
||||
var list = new List<ExternalAudioStream>(audios.Count);
|
||||
foreach (var a in audios)
|
||||
{
|
||||
list.Add(
|
||||
new ExternalAudioStream
|
||||
{
|
||||
FileFullPath = audioPath,
|
||||
StreamOrdinal = ordinal++,
|
||||
CodecName = string.IsNullOrWhiteSpace(a.CodecName) ? "?" : a.CodecName,
|
||||
TitleFromProbe = string.IsNullOrWhiteSpace(a.Title) ? null : a.Title.Trim(),
|
||||
Channels = a.Channels,
|
||||
SampleRateHz = a.SampleRateHz,
|
||||
BitRateBps = a.BitRateBps
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logging?.Warning($"ffprobe sidecar аудио: {ex.Message} — {audioPath}", "conversion.sidecar");
|
||||
return CreateSingleFallbackStream(audioPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static ExternalAudioStream[] CreateSingleFallbackStream(string audioPath) =>
|
||||
[
|
||||
new ExternalAudioStream
|
||||
{
|
||||
FileFullPath = audioPath,
|
||||
StreamOrdinal = 0,
|
||||
CodecName = GuessCodecFromExtension(Path.GetExtension(audioPath)),
|
||||
TitleFromProbe = null,
|
||||
Channels = null,
|
||||
SampleRateHz = null,
|
||||
BitRateBps = null
|
||||
}
|
||||
];
|
||||
|
||||
internal static string GuessCodecFromExtension(string? ext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ext))
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
ext = ext.Trim().ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".ac3" => "ac3",
|
||||
".eac3" => "eac3",
|
||||
".dts" => "dts",
|
||||
".aac" => "aac",
|
||||
".mp3" => "mp3",
|
||||
".opus" => "opus",
|
||||
".ogg" => "vorbis",
|
||||
".flac" => "flac",
|
||||
".wav" => "pcm_s16le",
|
||||
".wma" => "wmav2",
|
||||
".mka" or ".mkv" or ".mp4" or ".m4a" => "unknown",
|
||||
_ => ext.TrimStart('.')
|
||||
};
|
||||
}
|
||||
}
|
||||
128
EmbyToolbox/Services/SidecarTitleResolver.cs
Normal file
128
EmbyToolbox/Services/SidecarTitleResolver.cs
Normal file
@ -0,0 +1,128 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Распознаёт человекочитаемый title внешних audio/subtitle sidecar-файлов.</summary>
|
||||
public sealed class SidecarTitleResolver
|
||||
{
|
||||
private static readonly char[] StartDelimiters = ['.', '_', '-', ' '];
|
||||
private static readonly string[] TechnicalTailTokens = ["rus", "eng", "audio", "sub", "subtitle", "subtitles"];
|
||||
|
||||
public string ResolveExternalAudioTitle(
|
||||
string videoPath,
|
||||
string sidecarPath,
|
||||
int streamIndex,
|
||||
string? streamTags)
|
||||
{
|
||||
var tagTitle = NormalizeTitleOrNull(streamTags);
|
||||
if (tagTitle is not null)
|
||||
{
|
||||
return tagTitle;
|
||||
}
|
||||
|
||||
var byName = TryRecognizeSidecarTitle(videoPath, sidecarPath);
|
||||
if (byName is not null)
|
||||
{
|
||||
return byName;
|
||||
}
|
||||
|
||||
return BuildRusAudioFallback(streamIndex);
|
||||
}
|
||||
|
||||
public string ResolveExternalSubtitleTitle(string videoPath, string sidecarPath, int index)
|
||||
{
|
||||
var byName = TryRecognizeSidecarTitle(videoPath, sidecarPath);
|
||||
if (byName is not null)
|
||||
{
|
||||
return byName;
|
||||
}
|
||||
|
||||
return BuildRusSubtitleFallback(index);
|
||||
}
|
||||
|
||||
public string? TryRecognizeSidecarTitle(string videoPath, string sidecarPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoPath) || string.IsNullOrWhiteSpace(sidecarPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var videoBase = Path.GetFileNameWithoutExtension(videoPath);
|
||||
var sidecarBase = Path.GetFileNameWithoutExtension(sidecarPath);
|
||||
if (string.IsNullOrWhiteSpace(videoBase) || string.IsNullOrWhiteSpace(sidecarBase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sidecarBase.StartsWith(videoBase, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sidecarBase.Length <= videoBase.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var delimiter = sidecarBase[videoBase.Length];
|
||||
if (Array.IndexOf(StartDelimiters, delimiter) < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rawSuffix = sidecarBase[(videoBase.Length + 1)..];
|
||||
return CleanupCandidate(rawSuffix);
|
||||
}
|
||||
|
||||
private static string? CleanupCandidate(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cleaned = raw.Trim().Trim('.', '_', '-', ' ');
|
||||
if (string.IsNullOrWhiteSpace(cleaned))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
cleaned = cleaned.Replace('.', ' ')
|
||||
.Replace('_', ' ')
|
||||
.Replace('-', ' ');
|
||||
cleaned = Regex.Replace(cleaned, "\\s+", " ").Trim();
|
||||
if (string.IsNullOrWhiteSpace(cleaned))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokens = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
while (tokens.Count > 0 && TechnicalTailTokens.Contains(tokens[^1], StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
tokens.RemoveAt(tokens.Count - 1);
|
||||
}
|
||||
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
cleaned = string.Join(' ', tokens).Trim();
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
|
||||
}
|
||||
|
||||
public static string BuildRusAudioFallback(int index) => index <= 1 ? "RUS" : $"RUS {index}";
|
||||
public static string BuildRusSubtitleFallback(int index) => index <= 0 ? "RUS" : $"RUS {index}";
|
||||
|
||||
public static string? NormalizeTitleOrNull(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = title.Trim();
|
||||
return normalized.Equals("unknown", StringComparison.OrdinalIgnoreCase) ? null : normalized;
|
||||
}
|
||||
}
|
||||
108
EmbyToolbox/Services/SnapshotScopePaths.cs
Normal file
108
EmbyToolbox/Services/SnapshotScopePaths.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using System.IO;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Область применения snapshot: каталог эпизода и опционально общий корень батча.</summary>
|
||||
public static class SnapshotScopePaths
|
||||
{
|
||||
private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
/// <summary>Абсолютный путь без завершающего разделителя.</summary>
|
||||
public static string NormalizeScopeDirectory(string directoryPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directoryPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directoryPath.Trim()));
|
||||
}
|
||||
|
||||
public static string NormalizeScopeBatchRootNullable(string? path) =>
|
||||
string.IsNullOrWhiteSpace(path) ? string.Empty : NormalizeScopeDirectory(path);
|
||||
|
||||
public static string GetVideoScopeDirectory(string videoFileFullPath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(Path.GetFullPath(videoFileFullPath));
|
||||
return dir is null ? string.Empty : NormalizeScopeDirectory(dir);
|
||||
}
|
||||
|
||||
/// <summary>Узкий общий предок каталогов всех указанных видеофайлов (полные пути).</summary>
|
||||
public static string? TryGetLowestCommonAncestorDirectory(IReadOnlyList<string> videoFileAbsolutePaths)
|
||||
{
|
||||
if (videoFileAbsolutePaths.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dirs = videoFileAbsolutePaths
|
||||
.Select(static p =>
|
||||
{
|
||||
var d = Path.GetDirectoryName(Path.GetFullPath(p));
|
||||
return d is null ? string.Empty : NormalizeScopeDirectory(d);
|
||||
})
|
||||
.Where(static d => d.Length > 0)
|
||||
.Distinct(IC)
|
||||
.ToList();
|
||||
|
||||
if (dirs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var acc = dirs[0];
|
||||
for (var i = 1; i < dirs.Count; i++)
|
||||
{
|
||||
acc = LowestCommonAncestorOfTwoDirectories(acc, dirs[i]);
|
||||
if (string.IsNullOrEmpty(acc))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
private static string LowestCommonAncestorOfTwoDirectories(string dirA, string dirB)
|
||||
{
|
||||
if (dirA.Length == 0 || dirB.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var ancestorsA = new HashSet<string>(EnumerateAncestorDirectoriesInclusive(dirA), IC);
|
||||
foreach (var cand in EnumerateAncestorDirectoriesInclusive(dirB))
|
||||
{
|
||||
if (ancestorsA.Contains(cand))
|
||||
{
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>От указанной папки вверх к корню включая сам каталог.</summary>
|
||||
private static IEnumerable<string> EnumerateAncestorDirectoriesInclusive(string normalizedAbsoluteDir)
|
||||
{
|
||||
var d = NormalizeScopeDirectory(normalizedAbsoluteDir);
|
||||
while (!string.IsNullOrEmpty(d))
|
||||
{
|
||||
yield return d;
|
||||
|
||||
var parent = Path.GetDirectoryName(d);
|
||||
if (string.IsNullOrEmpty(parent))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
parent = NormalizeScopeDirectory(parent);
|
||||
if (string.Equals(parent, d, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
d = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
EmbyToolbox/Services/SubtitleCodecRules.cs
Normal file
138
EmbyToolbox/Services/SubtitleCodecRules.cs
Normal file
@ -0,0 +1,138 @@
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Правила совместимости субтитров с контейнером MKV/MP4 и codec copy в FFmpeg.</summary>
|
||||
public static class SubtitleCodecRules
|
||||
{
|
||||
public static string Normalize(string? codec) =>
|
||||
string.IsNullOrWhiteSpace(codec) ? string.Empty : codec.Trim().ToLowerInvariant();
|
||||
|
||||
public static bool IsTeletext(string? codec) => Normalize(codec) == "dvb_teletext";
|
||||
|
||||
public static bool IsUnknownSubtitleCodec(string? codec)
|
||||
{
|
||||
var n = Normalize(codec);
|
||||
return n is "" or "?" or "unknown";
|
||||
}
|
||||
|
||||
/// <summary>Кодеки, которые можно mux copy в Matroska (без teletext / mov_text и пр.).</summary>
|
||||
public static bool IsMkvCopySafe(string? codec)
|
||||
{
|
||||
return Normalize(codec) switch
|
||||
{
|
||||
"subrip" or "srt" or "ass" or "ssa" => true,
|
||||
"webvtt" or "wvtt" => true,
|
||||
"hdmv_pgs_subtitle" or "pgssub" => true,
|
||||
"dvd_subtitle" or "dvdsub" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsMp4TextDirectCopy(string? codec) => Normalize(codec) == "mov_text";
|
||||
|
||||
/// <summary>Текстовые дорожки, для MP4 требующие перекодирования в mov_text (не mux copy).</summary>
|
||||
public static bool Mp4RequiresSubtitleTranscode(string? codec)
|
||||
{
|
||||
return Normalize(codec) switch
|
||||
{
|
||||
"subrip" or "srt" or "ass" or "ssa" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TargetsMkv(string? container) =>
|
||||
!string.IsNullOrWhiteSpace(container) &&
|
||||
(container.Contains("mkv", StringComparison.OrdinalIgnoreCase) ||
|
||||
container.Contains("matro", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static bool TargetsMp4(string? container) =>
|
||||
!string.IsNullOrWhiteSpace(container) &&
|
||||
(container.Contains("mp4", StringComparison.OrdinalIgnoreCase) ||
|
||||
container.Contains("m4v", StringComparison.OrdinalIgnoreCase) ||
|
||||
container.Contains("mov", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Действо по умолчанию для встроенной дорожки субтитров после анализа.</summary>
|
||||
public static TrackActionKind DefaultEmbeddedSubtitleAction(string? codecName, string? profileContainer)
|
||||
{
|
||||
var c = codecName;
|
||||
|
||||
if (IsTeletext(c) || IsUnknownSubtitleCodec(c))
|
||||
{
|
||||
return TrackActionKind.Remove;
|
||||
}
|
||||
|
||||
if (TargetsMkv(profileContainer))
|
||||
{
|
||||
return IsMkvCopySafe(c) ? TrackActionKind.Keep : TrackActionKind.Remove;
|
||||
}
|
||||
|
||||
if (TargetsMp4(profileContainer))
|
||||
{
|
||||
if (IsMp4TextDirectCopy(c))
|
||||
{
|
||||
return TrackActionKind.Keep;
|
||||
}
|
||||
|
||||
if (Mp4RequiresSubtitleTranscode(c))
|
||||
{
|
||||
return TrackActionKind.Convert;
|
||||
}
|
||||
|
||||
return TrackActionKind.Remove;
|
||||
}
|
||||
|
||||
return IsMkvCopySafe(c) ? TrackActionKind.Keep : TrackActionKind.Remove;
|
||||
}
|
||||
|
||||
/// <summary>Встроенную субдорожку с этим решением включают в ffmpeg -map только если истина.</summary>
|
||||
public static bool ShouldMapEmbeddedSubtitle(
|
||||
MediaAnalysisResult media,
|
||||
TrackOverrideEntry t,
|
||||
string effectiveContainer)
|
||||
{
|
||||
if (t.Source != SourceKind.Embedded || t.StreamKind != MediaStreamKind.Subtitle)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (t.Action == TrackActionKind.Remove)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName;
|
||||
|
||||
if (TargetsMkv(effectiveContainer))
|
||||
{
|
||||
if (!IsMkvCopySafe(codec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return t.Action is TrackActionKind.Keep or TrackActionKind.Convert;
|
||||
}
|
||||
|
||||
if (TargetsMp4(effectiveContainer))
|
||||
{
|
||||
if (IsMp4TextDirectCopy(codec) && t.Action == TrackActionKind.Keep)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Mp4RequiresSubtitleTranscode(codec) &&
|
||||
t.Action is TrackActionKind.Convert or TrackActionKind.Keep)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsMkvCopySafe(codec) ? t.Action != TrackActionKind.Remove : false;
|
||||
}
|
||||
|
||||
/// <summary>Строковая подпись Codec для дорожки (субтитры отображать как текстовые / teletext).</summary>
|
||||
public static string EmbeddedSubtitleCodecLabel(string? ffprobeCodec) =>
|
||||
IsTeletext(ffprobeCodec) ? "dvb_teletext (subtitle)" : ffprobeCodec ?? "?";
|
||||
}
|
||||
33
EmbyToolbox/Services/SupportedVideoFormats.cs
Normal file
33
EmbyToolbox/Services/SupportedVideoFormats.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Единый список поддерживаемых расширений видео для очереди, объединения и анализа.</summary>
|
||||
public static class SupportedVideoFormats
|
||||
{
|
||||
private static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".ts", ".m2ts", ".webm", ".mpeg", ".mpg", ".m4v",
|
||||
".3gp", ".ogv", ".vob", ".rmvb", ".asf", ".divx", ".f4v", ".mts", ".m2v", ".mp2", ".mpv", ".qt",
|
||||
".hevc", ".h265", ".h264",
|
||||
};
|
||||
|
||||
public static bool IsSupportedVideoFile(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
return !string.IsNullOrWhiteSpace(extension) && Extensions.Contains(extension);
|
||||
}
|
||||
|
||||
/// <summary>Фильтр для OpenFileDialog: только поддерживаемые видео.</summary>
|
||||
public static string BuildOpenFileDialogFilter()
|
||||
{
|
||||
var globs = string.Join(";", Extensions.OrderBy(e => e, StringComparer.Ordinal).Select(e => $"*{e}"));
|
||||
return $"Видеофайлы|{globs}|Все файлы|*.*";
|
||||
}
|
||||
}
|
||||
76
EmbyToolbox/Services/TrackExtractOutputPaths.cs
Normal file
76
EmbyToolbox/Services/TrackExtractOutputPaths.cs
Normal file
@ -0,0 +1,76 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Единый каталог <c>extract\audio|subtitles|attachments</c> и имена без молчаливого перезаписывания.</summary>
|
||||
public static class TrackExtractOutputPaths
|
||||
{
|
||||
/// <returns>Абсолютный путь к каталогу <c>…\extract</c> или null.</returns>
|
||||
public static string? TryPrepareExtractDirectories(string destinationRootFolder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(destinationRootFolder))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = Path.GetFullPath(destinationRootFolder.Trim());
|
||||
var extractRoot = Path.Combine(root, "extract");
|
||||
Directory.CreateDirectory(Path.Combine(extractRoot, "audio"));
|
||||
Directory.CreateDirectory(Path.Combine(extractRoot, "subtitles"));
|
||||
Directory.CreateDirectory(Path.Combine(extractRoot, "attachments"));
|
||||
return extractRoot;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Если в <paramref name="parentDirectory"/> уже есть файл с таким именем — добавляет <c>_1</c>, <c>_2</c>… перед расширением.</summary>
|
||||
public static string AllocateUniqueFilename(string parentDirectory, string desiredFileNameOnly)
|
||||
{
|
||||
var safe = Path.GetFileName(desiredFileNameOnly);
|
||||
if (string.IsNullOrEmpty(safe))
|
||||
{
|
||||
safe = "output.bin";
|
||||
}
|
||||
|
||||
if (!Exists(parentDirectory, safe))
|
||||
{
|
||||
return safe;
|
||||
}
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(safe);
|
||||
var ext = Path.GetExtension(safe);
|
||||
|
||||
if (string.IsNullOrEmpty(stem))
|
||||
{
|
||||
stem = "file";
|
||||
}
|
||||
|
||||
for (var i = 1; i < 10_000; i++)
|
||||
{
|
||||
var candidate = $"{stem}_{i.ToString(CultureInfo.InvariantCulture)}{ext}";
|
||||
if (!Exists(parentDirectory, candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $"{stem}_{Guid.NewGuid():N}{ext}";
|
||||
}
|
||||
|
||||
private static bool Exists(string parentDirectory, string fileNameOnly) =>
|
||||
File.Exists(Path.Combine(parentDirectory, fileNameOnly));
|
||||
}
|
||||
55
EmbyToolbox/Services/TrackExtractionFormats.cs
Normal file
55
EmbyToolbox/Services/TrackExtractionFormats.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.IO;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Разрешённые контейнеры для извлечения дорожек.</summary>
|
||||
public static class TrackExtractionFormats
|
||||
{
|
||||
private static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mkv", ".mp4",
|
||||
};
|
||||
|
||||
public static bool IsSupportedPath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(path);
|
||||
return !string.IsNullOrWhiteSpace(ext) && Extensions.Contains(ext);
|
||||
}
|
||||
|
||||
public static string BuildOpenFileFilter() => "MKV и MP4|*.mkv;*.mp4|Все файлы|*.*";
|
||||
|
||||
public static IReadOnlyList<string> EnumerateMediaFilesRecursive(string rootDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var bag = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(rootDirectory, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (IsSupportedPath(file))
|
||||
{
|
||||
bag.Add(Path.GetFullPath(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// игнорируем недоступные подкаталоги
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
|
||||
bag.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return bag;
|
||||
}
|
||||
}
|
||||
110
EmbyToolbox/Services/TrackExtractionService.cs
Normal file
110
EmbyToolbox/Services/TrackExtractionService.cs
Normal file
@ -0,0 +1,110 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class TrackExtractionService
|
||||
{
|
||||
private readonly FfprobeService _ffprobe;
|
||||
|
||||
public TrackExtractionService(FfprobeService ffprobe)
|
||||
{
|
||||
_ffprobe = ffprobe;
|
||||
}
|
||||
|
||||
public async Task<MediaAnalysisResult?> AnalyzeMediaAsync(string filePath, LoggingService logging, CancellationToken ct)
|
||||
{
|
||||
var probe = await _ffprobe.AnalyzeAsync(filePath, ct).ConfigureAwait(false);
|
||||
if (!probe.IsSuccess)
|
||||
{
|
||||
logging.Error($"ffprobe: {probe.Error}", "tracks.extract");
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = MediaAnalysisParser.TryParse(probe.Json);
|
||||
if (parsed is null)
|
||||
{
|
||||
logging.Error($"ffprobe JSON не распознан: {Path.GetFileName(filePath)}", "tracks.extract");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// <summary>Создаёт <c>[destination]\extract\{audio|subtitles|attachments}</c>, возвращает путь к <c>extract</c>.</summary>
|
||||
public string? PrepareExtractLayout(string destinationRootFolder) =>
|
||||
TrackExtractOutputPaths.TryPrepareExtractDirectories(destinationRootFolder);
|
||||
|
||||
/// <returns>Успех, stderr ffmpeg.</returns>
|
||||
public async Task<(bool Success, string StdErr)> RunExtractProcessAsync(IReadOnlyList<string> ffmpegArgumentList, CancellationToken ct)
|
||||
{
|
||||
var exe = ResolveFfmpegPath();
|
||||
if (!File.Exists(exe))
|
||||
{
|
||||
return (false, $"ffmpeg не найден: {exe}");
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
foreach (var a in ffmpegArgumentList)
|
||||
{
|
||||
start.ArgumentList.Add(a);
|
||||
}
|
||||
|
||||
using var p = new Process { StartInfo = start };
|
||||
p.Start();
|
||||
using var reg = ct.Register(static state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state is not Process proc || proc.HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
proc.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}, p, useSynchronizationContext: false);
|
||||
|
||||
var stderrTask = p.StandardError.ReadToEndAsync(ct);
|
||||
var stdoutTask = p.StandardOutput.ReadToEndAsync(ct);
|
||||
try
|
||||
{
|
||||
await p.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(stderrTask, stdoutTask).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.WhenAll(stderrTask, stdoutTask).ConfigureAwait(false);
|
||||
var err = stderrTask.Result;
|
||||
var ok = p.ExitCode == 0 && !ct.IsCancellationRequested;
|
||||
return (ok, err.Trim());
|
||||
}
|
||||
|
||||
private static string ResolveFfmpegPath() => Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe");
|
||||
}
|
||||
505
EmbyToolbox/Services/TrackOverrideSeeder.cs
Normal file
505
EmbyToolbox/Services/TrackOverrideSeeder.cs
Normal file
@ -0,0 +1,505 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Заполняет <see cref="ConversionTaskOverride"/> значениями по умолчанию.</summary>
|
||||
public static class TrackOverrideSeeder
|
||||
{
|
||||
public static void EnsureDefaults(
|
||||
ConversionTaskOverride o,
|
||||
MediaAnalysisResult? media,
|
||||
IReadOnlyList<SidecarFile> side,
|
||||
ConversionProfileSettingsEntry profile,
|
||||
bool autoRemoveForeignTracks = false,
|
||||
IReadOnlyList<ExternalAudioFile>? externalAudio = null,
|
||||
string? videoPath = null,
|
||||
SidecarTitleResolver? sidecarTitleResolver = null,
|
||||
LoggingService? logging = null,
|
||||
bool disableSubtitleDefault = false)
|
||||
{
|
||||
if (o.TrackOverrides.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SyncTargetFieldsFromProfile(o, profile);
|
||||
|
||||
if (media is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var requiresVideoTranscode = ConversionPlanService.RequiresVideoTranscode(media, profile, o);
|
||||
var primaryVideoIndex = media.PrimaryVideo?.Index;
|
||||
foreach (var s in media.AllStreams)
|
||||
{
|
||||
var a = s.Kind == MediaStreamKind.Data ? TrackActionKind.Remove
|
||||
: s.Kind == MediaStreamKind.Attachment ? TrackActionKind.Keep
|
||||
: s.Kind == MediaStreamKind.Video
|
||||
? (primaryVideoIndex is { } pv && s.Index != pv
|
||||
? TrackActionKind.Remove
|
||||
: (requiresVideoTranscode && primaryVideoIndex is { } pv2 && s.Index == pv2
|
||||
? TrackActionKind.Convert
|
||||
: TrackActionKind.Keep))
|
||||
: s.Kind == MediaStreamKind.Audio
|
||||
? (ConversionPlanService.EmbeddedAudioMatchesProfile(s.CodecName, profile) ? TrackActionKind.Keep : TrackActionKind.Convert)
|
||||
: s.Kind == MediaStreamKind.Subtitle
|
||||
? SubtitleCodecRules.DefaultEmbeddedSubtitleAction(s.CodecName, profile.Container)
|
||||
: TrackActionKind.Keep;
|
||||
if (autoRemoveForeignTracks
|
||||
&& s.Kind is MediaStreamKind.Audio or MediaStreamKind.Subtitle
|
||||
&& IsForeignLanguageForAutoRemove(s.Language))
|
||||
{
|
||||
a = TrackActionKind.Remove;
|
||||
}
|
||||
|
||||
o.TrackOverrides.Add(
|
||||
new TrackOverrideEntry
|
||||
{
|
||||
StreamIndex = s.Index,
|
||||
Source = SourceKind.Embedded,
|
||||
StreamKind = s.Kind,
|
||||
Action = a,
|
||||
Default = s.Kind is MediaStreamKind.Audio or MediaStreamKind.Subtitle ? s.IsDefault : null,
|
||||
Language = s.Language,
|
||||
Title = s.Title,
|
||||
AudioBitrateKbps = s.Kind == MediaStreamKind.Audio && a == TrackActionKind.Convert ? "256 kbps" : null
|
||||
});
|
||||
}
|
||||
|
||||
var synthIndex = -1000;
|
||||
|
||||
var supportsAttachments = SupportsAttachments(profile.Container);
|
||||
var titleResolver = sidecarTitleResolver ?? new SidecarTitleResolver();
|
||||
var effectiveVideoPath = string.IsNullOrWhiteSpace(videoPath) ? InferVideoPathFromSidecars(side) : videoPath!;
|
||||
var subtitleSidecars = side.Where(s => s.IsSubtitle).ToList();
|
||||
foreach (var sc in side)
|
||||
{
|
||||
if (sc.IsAudio)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var streamKind = sc.IsFont ? MediaStreamKind.Attachment : MediaStreamKind.Subtitle;
|
||||
string? externalTitle;
|
||||
if (streamKind == MediaStreamKind.Subtitle)
|
||||
{
|
||||
var ord = subtitleSidecars.FindIndex(s =>
|
||||
string.Equals(s.FullPath, sc.FullPath, StringComparison.OrdinalIgnoreCase))
|
||||
+ 1;
|
||||
var subtitleIndexForFallback = subtitleSidecars.Count > 1 ? ord : 0;
|
||||
externalTitle = titleResolver.ResolveExternalSubtitleTitle(
|
||||
effectiveVideoPath,
|
||||
sc.FullPath,
|
||||
subtitleIndexForFallback);
|
||||
LogRecognizedOrFallback(logging, externalTitle);
|
||||
}
|
||||
else
|
||||
{
|
||||
externalTitle = Path.GetFileName(sc.FullPath);
|
||||
}
|
||||
|
||||
o.TrackOverrides.Add(
|
||||
new TrackOverrideEntry
|
||||
{
|
||||
StreamIndex = synthIndex--,
|
||||
ExternalPath = sc.FullPath,
|
||||
Source = SourceKind.External,
|
||||
StreamKind = streamKind,
|
||||
Action = streamKind == MediaStreamKind.Attachment && !supportsAttachments ? TrackActionKind.Remove : TrackActionKind.Add,
|
||||
Default = streamKind == MediaStreamKind.Attachment ? null : true,
|
||||
Language = streamKind == MediaStreamKind.Attachment ? null : "rus",
|
||||
Title = externalTitle
|
||||
});
|
||||
}
|
||||
|
||||
var resolvedAudio = ResolveExternalAudioFiles(side, externalAudio);
|
||||
var flattened = new List<(ExternalAudioFile file, ExternalAudioStream st)>();
|
||||
foreach (var audioFile in resolvedAudio)
|
||||
{
|
||||
foreach (var st in audioFile.Streams)
|
||||
{
|
||||
flattened.Add((audioFile, st));
|
||||
}
|
||||
}
|
||||
|
||||
var totalExtStreams = flattened.Count;
|
||||
var firstExternalAudioSet = false;
|
||||
var sameFileCounters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var commonTitleByFile = BuildCommonTitleByExternalAudioFile(flattened, effectiveVideoPath, titleResolver);
|
||||
for (var flatIdx = 0; flatIdx < flattened.Count; flatIdx++)
|
||||
{
|
||||
var audioFile = flattened[flatIdx].file;
|
||||
var st = flattened[flatIdx].st;
|
||||
var isFirstExternalAudio = !firstExternalAudioSet;
|
||||
if (isFirstExternalAudio)
|
||||
{
|
||||
firstExternalAudioSet = true;
|
||||
}
|
||||
|
||||
var oneBasedOrdinalAmongAllExternals = flatIdx + 1;
|
||||
var details = FormatExternalAudioDetails(st);
|
||||
var tagTitle = SidecarTitleResolver.NormalizeTitleOrNull(st.TitleFromProbe);
|
||||
var commonTitle = commonTitleByFile.GetValueOrDefault(audioFile.FullPath);
|
||||
|
||||
string displayTitle;
|
||||
if (tagTitle is not null)
|
||||
{
|
||||
displayTitle = tagTitle;
|
||||
LogRecognizedOrFallback(logging, displayTitle);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(commonTitle))
|
||||
{
|
||||
var sameFileOrdinal = sameFileCounters.TryGetValue(audioFile.FullPath, out var current)
|
||||
? current + 1
|
||||
: 1;
|
||||
sameFileCounters[audioFile.FullPath] = sameFileOrdinal;
|
||||
|
||||
displayTitle = audioFile.Streams.Count > 1
|
||||
? $"{commonTitle} {sameFileOrdinal}"
|
||||
: commonTitle!;
|
||||
LogRecognizedOrFallback(logging, displayTitle);
|
||||
}
|
||||
else
|
||||
{
|
||||
displayTitle = titleResolver.ResolveExternalAudioTitle(
|
||||
effectiveVideoPath,
|
||||
audioFile.FullPath,
|
||||
oneBasedOrdinalAmongAllExternals,
|
||||
streamTags: null);
|
||||
LogRecognizedOrFallback(logging, displayTitle);
|
||||
}
|
||||
|
||||
o.TrackOverrides.Add(
|
||||
new TrackOverrideEntry
|
||||
{
|
||||
StreamIndex = synthIndex--,
|
||||
ExternalPath = audioFile.FullPath,
|
||||
ExternalAudioStreamOrdinal = st.StreamOrdinal,
|
||||
SameFileExternalAudioStreamCount = audioFile.Streams.Count,
|
||||
ExternalStreamCodec = st.CodecName,
|
||||
ExternalStreamDetails = details,
|
||||
ExternalFfprobeTitle = tagTitle,
|
||||
Source = SourceKind.External,
|
||||
StreamKind = MediaStreamKind.Audio,
|
||||
Action = TrackActionKind.Add,
|
||||
Default = isFirstExternalAudio,
|
||||
Language = "rus",
|
||||
Title = displayTitle,
|
||||
AudioBitrateKbps = IsAacCodecName(st.CodecName) ? null : "256 kbps"
|
||||
});
|
||||
}
|
||||
|
||||
ApplySubtitleDefaultRules(o, media, logging, disableSubtitleDefault);
|
||||
}
|
||||
|
||||
/// <summary>RUS (один файл) или RUS 1, RUS 2, … (порядок как в <paramref name="sidecarSubtitleList"/>).</summary>
|
||||
public static string BuildExternalSubtitleDisplayTitle(int oneBasedIndex, int totalExternalSubtitles)
|
||||
{
|
||||
if (totalExternalSubtitles <= 0 || oneBasedIndex < 1)
|
||||
{
|
||||
return "RUS";
|
||||
}
|
||||
|
||||
return totalExternalSubtitles == 1 ? "RUS" : $"RUS {oneBasedIndex}";
|
||||
}
|
||||
|
||||
/// <summary>Каноническое title для внешних субтитров: распознанное имя либо fallback RUS / RUS N.</summary>
|
||||
public static string ExternalSubtitleCanonicalTitle(
|
||||
IReadOnlyList<TrackOverrideEntry> trackOrder,
|
||||
TrackOverrideEntry entry,
|
||||
string videoPath,
|
||||
SidecarTitleResolver? sidecarTitleResolver = null)
|
||||
{
|
||||
if (entry.Source != SourceKind.External
|
||||
|| entry.StreamKind != MediaStreamKind.Subtitle
|
||||
|| string.IsNullOrWhiteSpace(entry.ExternalPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var externalSubs = trackOrder
|
||||
.Where(t => t.Source == SourceKind.External
|
||||
&& t.StreamKind == MediaStreamKind.Subtitle
|
||||
&& !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||||
.ToList();
|
||||
|
||||
var idx = externalSubs.FindIndex(t =>
|
||||
string.Equals(t.ExternalPath, entry.ExternalPath, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var resolver = sidecarTitleResolver ?? new SidecarTitleResolver();
|
||||
var oneBased = idx >= 0 ? idx + 1 : 1;
|
||||
var subtitleIndexForFallback = externalSubs.Count > 1 ? oneBased : 0;
|
||||
return resolver.ResolveExternalSubtitleTitle(videoPath, entry.ExternalPath!, subtitleIndexForFallback);
|
||||
}
|
||||
|
||||
public static string ExternalAudioCanonicalTitleFromEntry(
|
||||
IReadOnlyList<TrackOverrideEntry> orderedTracks,
|
||||
TrackOverrideEntry entry,
|
||||
string videoPath,
|
||||
SidecarTitleResolver? sidecarTitleResolver = null)
|
||||
{
|
||||
if (entry.Source != SourceKind.External || entry.StreamKind != MediaStreamKind.Audio || string.IsNullOrWhiteSpace(entry.ExternalPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var list = orderedTracks
|
||||
.Where(t =>
|
||||
t.Source == SourceKind.External
|
||||
&& t.StreamKind == MediaStreamKind.Audio
|
||||
&& !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||||
.ToList();
|
||||
|
||||
var idx = list.FindIndex(t => ReferenceEquals(t, entry));
|
||||
if (idx < 0)
|
||||
{
|
||||
idx = list.FindIndex(
|
||||
t => string.Equals(t.ExternalPath, entry.ExternalPath, StringComparison.OrdinalIgnoreCase)
|
||||
&& t.ExternalAudioStreamOrdinal == entry.ExternalAudioStreamOrdinal);
|
||||
}
|
||||
|
||||
var oneBased = idx >= 0 ? idx + 1 : 1;
|
||||
var tag = SidecarTitleResolver.NormalizeTitleOrNull(entry.ExternalFfprobeTitle);
|
||||
if (tag is not null)
|
||||
{
|
||||
return tag;
|
||||
}
|
||||
|
||||
var resolver = sidecarTitleResolver ?? new SidecarTitleResolver();
|
||||
return resolver.ResolveExternalAudioTitle(videoPath, entry.ExternalPath!, oneBased, streamTags: null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExternalAudioFile> ResolveExternalAudioFiles(
|
||||
IReadOnlyList<SidecarFile> side,
|
||||
IReadOnlyList<ExternalAudioFile>? supplied)
|
||||
{
|
||||
if (supplied is { Count: > 0 })
|
||||
{
|
||||
return supplied;
|
||||
}
|
||||
|
||||
var paths = side.Where(s => s.IsAudio).Select(s => s.FullPath)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
List<ExternalAudioFile> fallback = [];
|
||||
foreach (var p in paths)
|
||||
{
|
||||
fallback.Add(CreateSingleStreamExternalFallback(p));
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static ExternalAudioFile CreateSingleStreamExternalFallback(string fullPath)
|
||||
{
|
||||
var ext = Path.GetExtension(fullPath);
|
||||
var stream = new ExternalAudioStream
|
||||
{
|
||||
FileFullPath = fullPath,
|
||||
StreamOrdinal = 0,
|
||||
CodecName = SidecarDiscoveryService.GuessCodecFromExtension(ext),
|
||||
TitleFromProbe = null,
|
||||
Channels = null,
|
||||
SampleRateHz = null,
|
||||
BitRateBps = null
|
||||
};
|
||||
return new ExternalAudioFile(fullPath, new[] { stream });
|
||||
}
|
||||
|
||||
private static string FormatExternalAudioDetails(ExternalAudioStream st)
|
||||
{
|
||||
var ch = st.Channels?.ToString(CultureInfo.InvariantCulture) ?? "?";
|
||||
var sr = st.SampleRateHz?.ToString(CultureInfo.InvariantCulture) ?? "?";
|
||||
var bit = st.BitRateBps is { } br ? $"{((br + 500) / 1000)} kbps" : "?";
|
||||
return $"{st.FileFullPath} | stream #{st.StreamOrdinal + 1} | {ch} ch | {sr} Hz | {bit}";
|
||||
}
|
||||
|
||||
public static void SyncTargetFieldsFromProfile(ConversionTaskOverride o, ConversionProfileSettingsEntry profile)
|
||||
{
|
||||
o.TargetContainer = profile.Container;
|
||||
o.TargetVideo = profile.Video;
|
||||
o.TargetPixelFormat = profile.PixelFormat;
|
||||
o.TargetResolution = profile.Resolution;
|
||||
o.TargetFps = profile.Fps;
|
||||
o.TargetAudioBitrate = profile.Bitrate;
|
||||
o.TargetVideoBitrateMode = profile.VideoBitrateMode;
|
||||
o.TargetVideoBitrateMbps = profile.VideoBitrateMbps;
|
||||
}
|
||||
|
||||
private static bool SupportsAttachments(string? container) =>
|
||||
!string.IsNullOrWhiteSpace(container) &&
|
||||
(container.Contains("mkv", StringComparison.OrdinalIgnoreCase) || container.Contains("matro", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsForeignLanguageForAutoRemove(string? language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lang = language.Trim().ToLowerInvariant();
|
||||
if (lang is "und" or "unknown" or "?")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return lang is not ("rus" or "ru");
|
||||
}
|
||||
|
||||
private static bool IsAacCodecName(string? codec) =>
|
||||
!string.IsNullOrWhiteSpace(codec)
|
||||
&& codec.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string InferVideoPathFromSidecars(IReadOnlyList<SidecarFile> side)
|
||||
{
|
||||
if (side.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(side[0].FullPath) ?? string.Empty;
|
||||
return Path.Combine(dir, "__unknown__.mkv");
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildCommonTitleByExternalAudioFile(
|
||||
IReadOnlyList<(ExternalAudioFile file, ExternalAudioStream st)> flattened,
|
||||
string videoPath,
|
||||
SidecarTitleResolver resolver)
|
||||
{
|
||||
var result = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var grp in flattened.GroupBy(x => x.file.FullPath, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var anyTag = grp.Any(x => SidecarTitleResolver.NormalizeTitleOrNull(x.st.TitleFromProbe) is not null);
|
||||
if (anyTag)
|
||||
{
|
||||
result[grp.Key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[grp.Key] = resolver.TryRecognizeSidecarTitle(videoPath, grp.Key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void LogRecognizedOrFallback(LoggingService? logging, string title)
|
||||
{
|
||||
if (logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.StartsWith("RUS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logging.Debug($"sidecar title fallback: {title}", "conversion.sidecar");
|
||||
}
|
||||
else
|
||||
{
|
||||
logging.Debug($"sidecar title recognized: {title}", "conversion.sidecar");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySubtitleDefaultRules(ConversionTaskOverride taskOverride, MediaAnalysisResult media, LoggingService? logging, bool disableSubtitleDefault)
|
||||
{
|
||||
var subtitleEntries = taskOverride.TrackOverrides
|
||||
.Where(t => t.StreamKind == MediaStreamKind.Subtitle)
|
||||
.ToList();
|
||||
if (disableSubtitleDefault)
|
||||
{
|
||||
foreach (var entry in subtitleEntries)
|
||||
{
|
||||
entry.Default = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var forcedCandidates = media.SubtitleStreams
|
||||
.Where(s => s.IsForced)
|
||||
.OrderBy(s => s.Index)
|
||||
.ToList();
|
||||
var embeddedSubtitleEntries = subtitleEntries
|
||||
.Where(t => t.Source == SourceKind.Embedded && t.Action != TrackActionKind.Remove)
|
||||
.ToList();
|
||||
|
||||
if (forcedCandidates.Count > 0)
|
||||
{
|
||||
var selectedForcedIndex = forcedCandidates[0].Index;
|
||||
if (forcedCandidates.Count > 1)
|
||||
{
|
||||
logging?.Info("Найдено несколько forced-субтитров, default установлен только для первой дорожки.", "subtitle.forced");
|
||||
}
|
||||
|
||||
foreach (var entry in subtitleEntries)
|
||||
{
|
||||
entry.Default = false;
|
||||
}
|
||||
|
||||
var selectedEntry = embeddedSubtitleEntries.FirstOrDefault(t => t.StreamIndex == selectedForcedIndex);
|
||||
if (selectedEntry is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
selectedEntry.Default = true;
|
||||
logging?.Info($"subtitle default set to forced track: #{selectedEntry.StreamIndex}", "subtitle.forced");
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultEmbeddedAudio = taskOverride.TrackOverrides
|
||||
.FirstOrDefault(t => t.Source == SourceKind.Embedded
|
||||
&& t.StreamKind == MediaStreamKind.Audio
|
||||
&& t.Action != TrackActionKind.Remove
|
||||
&& t.Default == true);
|
||||
var defaultAudioIsRus = IsRussianLanguage(defaultEmbeddedAudio?.Language);
|
||||
|
||||
var rusFullSubtitleEntries = embeddedSubtitleEntries
|
||||
.Where(t => IsRussianLanguage(t.Language))
|
||||
.Where(t =>
|
||||
{
|
||||
var stream = media.SubtitleStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
|
||||
return stream is null || !stream.IsForced;
|
||||
})
|
||||
.OrderBy(t => t.StreamIndex)
|
||||
.ToList();
|
||||
|
||||
if (defaultAudioIsRus)
|
||||
{
|
||||
foreach (var entry in rusFullSubtitleEntries)
|
||||
{
|
||||
entry.Default = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (rusFullSubtitleEntries.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in subtitleEntries)
|
||||
{
|
||||
entry.Default = false;
|
||||
}
|
||||
|
||||
rusFullSubtitleEntries[0].Default = true;
|
||||
logging?.Info($"subtitle default set to first RUS full track: #{rusFullSubtitleEntries[0].StreamIndex}", "subtitle.forced");
|
||||
}
|
||||
|
||||
private static bool IsRussianLanguage(string? language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = language.Trim().ToLowerInvariant();
|
||||
return normalized is "ru" or "rus" or "russian" or "рус" or "русский";
|
||||
}
|
||||
}
|
||||
314
EmbyToolbox/Services/TrackSettingsSnapshotService.cs
Normal file
314
EmbyToolbox/Services/TrackSettingsSnapshotService.cs
Normal file
@ -0,0 +1,314 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
/// <summary>Сопоставление snapshot с текущим файлом: Type + Source + Language + номер дорожки в группе; видео/аудио/субтитры упорядочены по общему рангу.</summary>
|
||||
public sealed class TrackSettingsSnapshotService
|
||||
{
|
||||
private const string LogModule = "conversion.snapshot";
|
||||
|
||||
/// <summary>Только пробельные символы (включая переносы).</summary>
|
||||
private static readonly Regex WhitespaceCollapse = new(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Символы кавычек и типографских кавычек.</summary>
|
||||
private static readonly Regex QuoteStrip = new(@"[""'`´«»„“”‚‘’]", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Спецсимволы помимо букв/цифр/пробела.</summary>
|
||||
private static readonly Regex NoiseStrip = new(@"[^\p{L}\p{N}\s]", RegexOptions.Compiled);
|
||||
|
||||
private TrackSettingsSnapshot? _lastSnapshot;
|
||||
private readonly LoggingService? _logging;
|
||||
|
||||
public TrackSettingsSnapshotService(LoggingService? logging = null)
|
||||
{
|
||||
_logging = logging;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// batchScopeRootOptional: корень добавления («добавить каталог», общий предок файлов дропом); пустое — сохранять только каталог файла как область точного попадания.
|
||||
/// </summary>
|
||||
public void SaveSnapshot(string filePath, IReadOnlyList<TrackSettingsSnapshotItem> trackPlans, string? batchScopeRootOptional)
|
||||
{
|
||||
var scopeDirectory = SnapshotScopePaths.GetVideoScopeDirectory(filePath);
|
||||
var normalizedBatch = string.IsNullOrWhiteSpace(batchScopeRootOptional)
|
||||
? null
|
||||
: SnapshotScopePaths.NormalizeScopeDirectory(batchScopeRootOptional);
|
||||
_lastSnapshot = new TrackSettingsSnapshot
|
||||
{
|
||||
FilePath = filePath,
|
||||
ScopeDirectory = scopeDirectory,
|
||||
ScopeBatchRoot = normalizedBatch,
|
||||
Signature = BuildSignature(trackPlans),
|
||||
Items = trackPlans.ToArray()
|
||||
};
|
||||
_logging?.Info($"snapshot сохранен: {filePath} ({trackPlans.Count} дор.), scope:{scopeDirectory}, batchRoot:{(normalizedBatch ?? "-")}", LogModule);
|
||||
}
|
||||
|
||||
public SnapshotApplyResult TryApplySnapshot(
|
||||
IReadOnlyList<TrackSettingsSnapshotItem> currentTrackPlans,
|
||||
string currentVideoFullPath,
|
||||
string? currentBatchScopeRootOptional)
|
||||
{
|
||||
if (_lastSnapshot is null)
|
||||
{
|
||||
return new SnapshotApplyResult(false, SnapshotApplyDegree.None, SnapshotApplyReason.NoSnapshot, null);
|
||||
}
|
||||
|
||||
if (!IsAllowedSnapshotScope(currentVideoFullPath, currentBatchScopeRootOptional))
|
||||
{
|
||||
_logging?.Info(
|
||||
$"snapshot: область не совпадает — не применяем (текущая папка: {SnapshotScopePaths.GetVideoScopeDirectory(currentVideoFullPath)}; сохранены: {_lastSnapshot.ScopeDirectory}, batch:{_lastSnapshot.ScopeBatchRoot ?? "-"}) — источник {_lastSnapshot.FilePath}",
|
||||
LogModule);
|
||||
return new SnapshotApplyResult(false, SnapshotApplyDegree.None, SnapshotApplyReason.ScopeMismatch, null);
|
||||
}
|
||||
|
||||
var snapItems = _lastSnapshot.Items;
|
||||
var currents = AssignBucketKeys(currentTrackPlans);
|
||||
var snapMap = BuildSnapshotLookup(snapItems);
|
||||
|
||||
var rowResults = new List<TrackMatchResult>(currents.Count);
|
||||
|
||||
foreach (var (curItem, bucketKey) in currents)
|
||||
{
|
||||
if (snapMap.TryGetValue(bucketKey, out var src))
|
||||
{
|
||||
rowResults.Add(new TrackMatchResult
|
||||
{
|
||||
CurrentOrder = curItem.Order,
|
||||
IsMatched = true,
|
||||
Strategy = MatchingStrategy.OrdinalByTypeSourceLanguage,
|
||||
HadAmbiguousCandidates = false,
|
||||
SourceItem = src,
|
||||
ResolvedAction = src.Action
|
||||
});
|
||||
LogTitleSoftMismatchIfAny(curItem, src);
|
||||
}
|
||||
else
|
||||
{
|
||||
rowResults.Add(new TrackMatchResult
|
||||
{
|
||||
CurrentOrder = curItem.Order,
|
||||
IsMatched = false,
|
||||
Strategy = MatchingStrategy.None,
|
||||
HadAmbiguousCandidates = false,
|
||||
SourceItem = null,
|
||||
ResolvedAction = curItem.Action
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var matchedCount = rowResults.Count(r => r.IsMatched);
|
||||
|
||||
SnapshotApplyDegree degree;
|
||||
if (matchedCount == 0)
|
||||
{
|
||||
degree = SnapshotApplyDegree.None;
|
||||
}
|
||||
else if (matchedCount == currentTrackPlans.Count
|
||||
&& matchedCount == snapItems.Count)
|
||||
{
|
||||
degree = SnapshotApplyDegree.Full;
|
||||
}
|
||||
else
|
||||
{
|
||||
degree = SnapshotApplyDegree.Partial;
|
||||
}
|
||||
|
||||
var appliedAny = matchedCount > 0;
|
||||
WriteApplyLog(currentTrackPlans.Count, snapItems.Count, matchedCount, degree);
|
||||
|
||||
return new SnapshotApplyResult(appliedAny, degree, SnapshotApplyReason.Success, rowResults);
|
||||
}
|
||||
|
||||
/// <summary>Не менее двух дорожек с языком <c>und</c>: сопоставление только по очередности внутри группы Kind+Source+und.</summary>
|
||||
public static bool TracksHaveRiskyMultipleUndTracks(IReadOnlyList<TrackSettingsSnapshotItem> trackPlans) =>
|
||||
trackPlans
|
||||
.GroupBy(t => (t.StreamKind, t.Source, Lang: NormalizeLanguage(t.Language)))
|
||||
.Any(static g => g.Key.Lang == "und" && g.Count() > 1);
|
||||
|
||||
private bool IsAllowedSnapshotScope(string currentVideoFullPath, string? currentBatchScopeOptional)
|
||||
{
|
||||
if (_lastSnapshot is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapScope = string.IsNullOrWhiteSpace(_lastSnapshot.ScopeDirectory)
|
||||
? SnapshotScopePaths.GetVideoScopeDirectory(_lastSnapshot.FilePath)
|
||||
: SnapshotScopePaths.NormalizeScopeDirectory(_lastSnapshot.ScopeDirectory);
|
||||
|
||||
var curScope = SnapshotScopePaths.GetVideoScopeDirectory(currentVideoFullPath);
|
||||
if (string.Equals(curScope, snapScope, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var snapBatch = SnapshotScopePaths.NormalizeScopeBatchRootNullable(_lastSnapshot.ScopeBatchRoot);
|
||||
var curBatch = SnapshotScopePaths.NormalizeScopeBatchRootNullable(currentBatchScopeOptional);
|
||||
if (snapBatch.Length != 0 && curBatch.Length != 0
|
||||
&& string.Equals(snapBatch, curBatch, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Нормализация Title только для доп. сравнения (не ключ сопоставления).</summary>
|
||||
public static string NormalizeTitleFingerprint(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(title.Trim());
|
||||
sb.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
|
||||
var s = QuoteStrip.Replace(sb.ToString(), string.Empty);
|
||||
s = NoiseStrip.Replace(s, string.Empty);
|
||||
s = WhitespaceCollapse.Replace(s, " ").Trim();
|
||||
return string.IsNullOrEmpty(s) ? string.Empty : s.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void LogTitleSoftMismatchIfAny(TrackSettingsSnapshotItem current, TrackSettingsSnapshotItem snapshot)
|
||||
{
|
||||
var a = NormalizeTitleFingerprint(snapshot.Title);
|
||||
var b = NormalizeTitleFingerprint(current.Title);
|
||||
if (a.Length != 0 && b.Length != 0 && !string.Equals(a, b, StringComparison.Ordinal))
|
||||
{
|
||||
_logging?.Debug($"snapshot: title отличается (доп. проверка), ключ не затронут. порядок {current.Order}", LogModule);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteApplyLog(int currentCount, int snapCount, int matched, SnapshotApplyDegree degree)
|
||||
{
|
||||
var prev = _lastSnapshot?.FilePath ?? "?";
|
||||
if (degree == SnapshotApplyDegree.Full)
|
||||
{
|
||||
_logging?.Info(
|
||||
$"snapshot: применено по порядку дорожек (полное, {matched}/{currentCount}); источник {prev}",
|
||||
LogModule);
|
||||
return;
|
||||
}
|
||||
|
||||
if (degree == SnapshotApplyDegree.Partial)
|
||||
{
|
||||
_logging?.Info(
|
||||
$"snapshot: применено частично ({matched}/{currentCount} дорожек, в snapshot было {snapCount}); источник {prev}",
|
||||
LogModule);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentCount != snapCount)
|
||||
{
|
||||
_logging?.Info(
|
||||
$"snapshot: не применено — не удалось сопоставить ни одной дорожки (snapshot {snapCount}, текущее {currentCount}); источник {prev}",
|
||||
LogModule);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logging?.Info(
|
||||
$"snapshot: не применено — не удалось сопоставить дорожки (тип язык или номер в группе отличается); источник {prev}",
|
||||
LogModule);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct BucketKey(MediaStreamKind Kind, SourceKind Source, string LangNorm, int OrdinalInGroup);
|
||||
|
||||
private static IReadOnlyList<(TrackSettingsSnapshotItem Item, BucketKey Key)> AssignBucketKeys(
|
||||
IReadOnlyList<TrackSettingsSnapshotItem> items)
|
||||
{
|
||||
var sorted = items.OrderBy(x => StreamKindRank(x.StreamKind)).ThenBy(x => x.Order).ToList();
|
||||
var counts = new Dictionary<(MediaStreamKind, SourceKind, string), int>();
|
||||
var list = new List<(TrackSettingsSnapshotItem Item, BucketKey Key)>(sorted.Count);
|
||||
foreach (var it in sorted)
|
||||
{
|
||||
var lang = NormalizeLanguage(it.Language);
|
||||
var gKey = (it.StreamKind, it.Source, lang);
|
||||
var ordinal = counts.GetValueOrDefault(gKey, 0) + 1;
|
||||
counts[gKey] = ordinal;
|
||||
var bk = new BucketKey(it.StreamKind, it.Source, lang, ordinal);
|
||||
list.Add((it, bk));
|
||||
}
|
||||
|
||||
list.Sort((a, b) => a.Item.Order.CompareTo(b.Item.Order));
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Dictionary<BucketKey, TrackSettingsSnapshotItem> BuildSnapshotLookup(
|
||||
IReadOnlyList<TrackSettingsSnapshotItem> snapItems)
|
||||
{
|
||||
var dict = new Dictionary<BucketKey, TrackSettingsSnapshotItem>();
|
||||
foreach (var (item, key) in AssignBucketKeys(snapItems))
|
||||
{
|
||||
dict[key] = item;
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static int StreamKindRank(MediaStreamKind k) =>
|
||||
k switch
|
||||
{
|
||||
MediaStreamKind.Video => 0,
|
||||
MediaStreamKind.Audio => 1,
|
||||
MediaStreamKind.Subtitle => 2,
|
||||
MediaStreamKind.Attachment => 3,
|
||||
MediaStreamKind.Data => 4,
|
||||
_ => 99
|
||||
};
|
||||
|
||||
private static TrackStructureSignature BuildSignature(IReadOnlyList<TrackSettingsSnapshotItem> trackPlans) =>
|
||||
new()
|
||||
{
|
||||
Tracks = trackPlans
|
||||
.Select(x => new TrackStructureSignatureItem
|
||||
{
|
||||
Order = x.Order,
|
||||
StreamKind = x.StreamKind,
|
||||
Source = x.Source,
|
||||
Codec = (x.Codec ?? string.Empty).Trim(),
|
||||
Language = NormalizeLanguage(x.Language),
|
||||
Title = NormalizeTitleFingerprint(x.Title)
|
||||
})
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
/// <summary>Rus/eng/und и т.д.</summary>
|
||||
public static string NormalizeLanguage(string? language)
|
||||
{
|
||||
var raw = (language ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
{
|
||||
return "und";
|
||||
}
|
||||
|
||||
var t = raw.ToLowerInvariant();
|
||||
if (t is "und" or "unknown" or "?" or "??")
|
||||
{
|
||||
return "und";
|
||||
}
|
||||
|
||||
if (t.Length == 2)
|
||||
{
|
||||
return t switch
|
||||
{
|
||||
"ru" => "rus",
|
||||
"en" => "eng",
|
||||
"uk" => "ukr",
|
||||
"de" => "deu",
|
||||
"fr" => "fra",
|
||||
"es" => "spa",
|
||||
"it" => "ita",
|
||||
"ja" => "jpn",
|
||||
"ko" => "kor",
|
||||
"zh" => "zho",
|
||||
_ => t
|
||||
};
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
}
|
||||
56
EmbyToolbox/Services/TrackStructureComparer.cs
Normal file
56
EmbyToolbox/Services/TrackStructureComparer.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using System.Text;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed class TrackStructureComparer
|
||||
{
|
||||
public string BuildSignature(IReadOnlyList<TrackOverrideEntry> tracks)
|
||||
{
|
||||
var sb = new StringBuilder(tracks.Count * 40);
|
||||
for (var i = 0; i < tracks.Count; i++)
|
||||
{
|
||||
var t = tracks[i];
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append("||");
|
||||
}
|
||||
|
||||
sb.Append((int)t.Source);
|
||||
sb.Append('|');
|
||||
sb.Append((int)t.StreamKind);
|
||||
sb.Append('|');
|
||||
if (t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle)
|
||||
{
|
||||
sb.Append(NormalizeToken(t.Language));
|
||||
sb.Append('|');
|
||||
sb.Append(NormalizeToken(t.Title));
|
||||
sb.Append('|');
|
||||
}
|
||||
sb.Append(NormalizeToken(ResolveCodecToken(t)));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ResolveCodecToken(TrackOverrideEntry t)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(t.ExternalStreamCodec))
|
||||
{
|
||||
return t.ExternalStreamCodec!;
|
||||
}
|
||||
|
||||
return t.StreamKind == MediaStreamKind.Attachment ? "attachment" : string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeToken(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
102
EmbyToolbox/Services/VideoBitratePolicy.cs
Normal file
102
EmbyToolbox/Services/VideoBitratePolicy.cs
Normal file
@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public static class VideoBitratePolicy
|
||||
{
|
||||
public const string Auto = "Auto";
|
||||
public const string Source = "Source";
|
||||
public const string Custom = "Custom";
|
||||
|
||||
public static IReadOnlyList<string> UiOptions { get; } =
|
||||
[
|
||||
Auto,
|
||||
Source,
|
||||
"2 Mbps",
|
||||
"4 Mbps",
|
||||
"6 Mbps",
|
||||
"8 Mbps",
|
||||
"10 Mbps",
|
||||
"12 Mbps",
|
||||
"15 Mbps",
|
||||
"20 Mbps",
|
||||
Custom
|
||||
];
|
||||
|
||||
public static string NormalizeMode(string? mode) =>
|
||||
mode switch
|
||||
{
|
||||
Source => Source,
|
||||
Custom => Custom,
|
||||
_ => TryParseFixedModeKbps(mode) is { } fixedKbps
|
||||
? $"{fixedKbps / 1000.0:0.###} Mbps".Replace(".000", string.Empty, StringComparison.Ordinal)
|
||||
: Auto
|
||||
};
|
||||
|
||||
public static int? ResolveTargetKbps(string? mode, double? customMbps, MediaAnalysisResult? media)
|
||||
{
|
||||
var normalized = NormalizeMode(mode);
|
||||
if (normalized == Auto)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized == Source)
|
||||
{
|
||||
return media?.SourceVideoBitrateBps is { } bps && bps > 0
|
||||
? Math.Max(1, (int)Math.Round(bps / 1000.0, MidpointRounding.AwayFromZero))
|
||||
: null;
|
||||
}
|
||||
|
||||
if (normalized == Custom)
|
||||
{
|
||||
return customMbps is { } mbps && mbps > 0
|
||||
? Math.Max(1, (int)Math.Round(mbps * 1000.0, MidpointRounding.AwayFromZero))
|
||||
: null;
|
||||
}
|
||||
|
||||
return TryParseFixedModeKbps(normalized);
|
||||
}
|
||||
|
||||
public static int? TryParseFixedModeKbps(string? mode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var m = mode.Trim();
|
||||
if (m.Equals(Auto, StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals(Source, StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals(Custom, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!m.EndsWith("Mbps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var number = m[..^4].Trim();
|
||||
if (!double.TryParse(number.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var mbps) || mbps <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.Max(1, (int)Math.Round(mbps * 1000.0, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
|
||||
public static string FormatCurrentSource(long? bps)
|
||||
{
|
||||
if (bps is not { } value || value <= 0)
|
||||
{
|
||||
return "Текущее: неизвестно";
|
||||
}
|
||||
|
||||
var mbps = value / 1_000_000d;
|
||||
return $"Текущее: {mbps:0.###} Mbps";
|
||||
}
|
||||
}
|
||||
392
EmbyToolbox/Services/VideoInfoSummaryService.cs
Normal file
392
EmbyToolbox/Services/VideoInfoSummaryService.cs
Normal file
@ -0,0 +1,392 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.Services;
|
||||
|
||||
public sealed record SidecarAnalysisResult(
|
||||
string VideoPath,
|
||||
IReadOnlyList<SidecarFile> Sidecars,
|
||||
IReadOnlyList<ExternalAudioFile> ExternalAudioFiles);
|
||||
|
||||
public sealed class VideoInfoSummaryService
|
||||
{
|
||||
private readonly SidecarTitleResolver _titleResolver = new();
|
||||
|
||||
public string BuildSummary(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"Формат: {NormalizeContainer(media.ContainerFormat, sidecars.VideoPath)}",
|
||||
"Видео:",
|
||||
$"\t- {FormatVideoLine(media)}",
|
||||
"Аудио:"
|
||||
};
|
||||
|
||||
var audioLines = BuildAudioLines(media, sidecars);
|
||||
if (audioLines.Count == 0)
|
||||
{
|
||||
lines.Add("\t- ?");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var audioLine in audioLines)
|
||||
{
|
||||
lines.Add("\t- " + audioLine);
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add("Субтитры:");
|
||||
var subtitleLines = BuildSubtitleLines(media, sidecars);
|
||||
if (subtitleLines.Count == 0)
|
||||
{
|
||||
lines.Add("\t- ?");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var subtitleLine in subtitleLines)
|
||||
{
|
||||
lines.Add("\t- " + subtitleLine);
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentsLine = FormatAttachmentsLine(media);
|
||||
if (!string.IsNullOrWhiteSpace(attachmentsLine))
|
||||
{
|
||||
lines.Add(attachmentsLine);
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
public string NormalizeContainer(string? formatName, string videoPath)
|
||||
{
|
||||
var format = (formatName ?? string.Empty).ToLowerInvariant();
|
||||
var ext = Path.GetExtension(videoPath).ToLowerInvariant();
|
||||
if (format.Contains("matroska", StringComparison.Ordinal) || format.Contains("webm", StringComparison.Ordinal))
|
||||
{
|
||||
return ext switch
|
||||
{
|
||||
".webm" => "WebM",
|
||||
_ => "MKV"
|
||||
};
|
||||
}
|
||||
|
||||
if (format.Contains("mov", StringComparison.Ordinal)
|
||||
|| format.Contains("mp4", StringComparison.Ordinal)
|
||||
|| format.Contains("m4a", StringComparison.Ordinal)
|
||||
|| format.Contains("3gp", StringComparison.Ordinal)
|
||||
|| format.Contains("3g2", StringComparison.Ordinal)
|
||||
|| format.Contains("mj2", StringComparison.Ordinal))
|
||||
{
|
||||
return "MP4";
|
||||
}
|
||||
|
||||
if (format.Contains("avi", StringComparison.Ordinal))
|
||||
{
|
||||
return "AVI";
|
||||
}
|
||||
|
||||
if (format.Contains("mpegts", StringComparison.Ordinal))
|
||||
{
|
||||
return "TS";
|
||||
}
|
||||
|
||||
return format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault()?.ToUpperInvariant()
|
||||
?? "?";
|
||||
}
|
||||
|
||||
public string FormatVideoLine(MediaAnalysisResult media)
|
||||
{
|
||||
var v = media.PrimaryVideo;
|
||||
if (v is null)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
var codec = NormalizeVideoCodec(v);
|
||||
var profile = NormalizeVideoProfile(v);
|
||||
var resolution = v.Width is { } w && v.Height is { } h ? $"{w}x{h}" : "?";
|
||||
var bitrate = FormatBitrate(v.BitRateBps ?? media.FormatBitRateBps);
|
||||
var fps = FormatFps(v.AverageFrameRate ?? v.FrameRate);
|
||||
return $"{codec}{profile}, {resolution}, {bitrate}, {fps}";
|
||||
}
|
||||
|
||||
public string FormatAudioLine(string lang, bool isExternal, int index, string codec, int? sampleRateHz, int? channels, long? bitrateBps, string? title)
|
||||
{
|
||||
var ext = isExternal ? "(ext)" : string.Empty;
|
||||
var idx = index > 0 ? $" {index}" : string.Empty;
|
||||
var titlePart = string.IsNullOrWhiteSpace(title) ? string.Empty : $" [{title.Trim()}]";
|
||||
var channelPart = channels is { } ch ? $"{ch}ch" : "?ch";
|
||||
return $"{lang}{ext}{idx}: {codec}, {FormatSampleRate(sampleRateHz)}, {channelPart}, {FormatBitrate(bitrateBps)}{titlePart}";
|
||||
}
|
||||
|
||||
public string FormatSubtitleLine(string lang, bool isExternal, int index, string codec, string? title)
|
||||
{
|
||||
var ext = isExternal ? "(ext)" : string.Empty;
|
||||
var idx = index > 0 ? $" {index}" : string.Empty;
|
||||
var titlePart = string.IsNullOrWhiteSpace(title) ? string.Empty : $" [{title.Trim()}]";
|
||||
return $"{lang}{ext}{idx}: {codec}{titlePart}";
|
||||
}
|
||||
|
||||
public string? FormatAttachmentsLine(MediaAnalysisResult media)
|
||||
{
|
||||
var attachments = media.AllStreams.Where(s => s.Kind == MediaStreamKind.Attachment).ToList();
|
||||
if (attachments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fontCount = attachments.Count(IsFontAttachment);
|
||||
return fontCount > 0
|
||||
? $"Attachments: {attachments.Count} files, fonts: {fontCount}"
|
||||
: $"Attachments: {attachments.Count} files";
|
||||
}
|
||||
|
||||
private List<string> BuildAudioLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var counters = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var a in media.AudioStreams.OrderBy(x => x.Index))
|
||||
{
|
||||
var lang = NormalizeLanguage(a.Language, external: false);
|
||||
var key = $"in:{lang}";
|
||||
var nextIndex = AddAndGetIndex(counters, key);
|
||||
var displayIndex = nextIndex > 1 ? nextIndex : 0;
|
||||
lines.Add(FormatAudioLine(
|
||||
lang,
|
||||
isExternal: false,
|
||||
index: displayIndex,
|
||||
NormalizeAudioCodec(a.CodecName),
|
||||
a.SampleRateHz,
|
||||
a.Channels,
|
||||
a.BitRateBps,
|
||||
a.Title));
|
||||
}
|
||||
|
||||
var externalStreams = sidecars.ExternalAudioFiles
|
||||
.SelectMany(f => f.Streams.Select(s => (file: f, stream: s)))
|
||||
.ToList();
|
||||
var rusFallbackIndex = 0;
|
||||
foreach (var e in externalStreams)
|
||||
{
|
||||
var lang = "RUS";
|
||||
var key = $"ext:{lang}";
|
||||
var nextIndex = AddAndGetIndex(counters, key);
|
||||
var displayIndex = nextIndex > 1 ? nextIndex : 1;
|
||||
|
||||
string? title = SidecarTitleResolver.NormalizeTitleOrNull(e.stream.TitleFromProbe);
|
||||
if (title is null)
|
||||
{
|
||||
title = _titleResolver.TryRecognizeSidecarTitle(sidecars.VideoPath, e.file.FullPath);
|
||||
}
|
||||
|
||||
if (title is null)
|
||||
{
|
||||
rusFallbackIndex++;
|
||||
title = SidecarTitleResolver.BuildRusAudioFallback(rusFallbackIndex);
|
||||
}
|
||||
|
||||
lines.Add(FormatAudioLine(
|
||||
lang,
|
||||
isExternal: true,
|
||||
index: displayIndex,
|
||||
NormalizeAudioCodec(e.stream.CodecName),
|
||||
e.stream.SampleRateHz,
|
||||
e.stream.Channels,
|
||||
e.stream.BitRateBps,
|
||||
title));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private List<string> BuildSubtitleLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var counters = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var s in media.SubtitleStreams.OrderBy(x => x.Index))
|
||||
{
|
||||
var lang = NormalizeLanguage(s.Language, external: false);
|
||||
var key = $"in:{lang}";
|
||||
var nextIndex = AddAndGetIndex(counters, key);
|
||||
var displayIndex = nextIndex > 1 ? nextIndex : 0;
|
||||
lines.Add(FormatSubtitleLine(lang, false, displayIndex, NormalizeSubtitleCodec(s.CodecName), s.Title));
|
||||
}
|
||||
|
||||
var externalSubs = sidecars.Sidecars.Where(s => s.IsSubtitle).OrderBy(s => s.FileName, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var fallbackRusIdx = 0;
|
||||
foreach (var sub in externalSubs)
|
||||
{
|
||||
var lang = "RUS";
|
||||
var key = $"ext:{lang}";
|
||||
var nextIndex = AddAndGetIndex(counters, key);
|
||||
var displayIndex = nextIndex > 1 ? nextIndex : 0;
|
||||
var title = _titleResolver.TryRecognizeSidecarTitle(sidecars.VideoPath, sub.FullPath);
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
fallbackRusIdx++;
|
||||
title = SidecarTitleResolver.BuildRusSubtitleFallback(fallbackRusIdx);
|
||||
}
|
||||
|
||||
lines.Add(FormatSubtitleLine(lang, true, displayIndex, NormalizeSubtitleCodecByExtension(sub.FullPath), title));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static int AddAndGetIndex(Dictionary<string, int> counters, string key)
|
||||
{
|
||||
counters.TryGetValue(key, out var current);
|
||||
current++;
|
||||
counters[key] = current;
|
||||
return current;
|
||||
}
|
||||
|
||||
private static string NormalizeLanguage(string? language, bool external)
|
||||
{
|
||||
if (external && string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return "RUS";
|
||||
}
|
||||
|
||||
var l = (language ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return l switch
|
||||
{
|
||||
"" or "unknown" or "und" or "?" => "UND",
|
||||
"rus" or "ru" => "RUS",
|
||||
"jpn" or "ja" => "JPN",
|
||||
"eng" or "en" => "ENG",
|
||||
_ => l.ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeVideoCodec(MediaStreamInfo v)
|
||||
{
|
||||
var codec = (v.CodecName ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (codec.Contains("h264", StringComparison.Ordinal) || codec.Contains("avc", StringComparison.Ordinal))
|
||||
{
|
||||
return (v.Encoder ?? string.Empty).Contains("x264", StringComparison.OrdinalIgnoreCase) ? "x264" : "H.264";
|
||||
}
|
||||
|
||||
if (codec.Contains("hevc", StringComparison.Ordinal) || codec.Contains("h265", StringComparison.Ordinal))
|
||||
{
|
||||
return "H.265";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(v.CodecName) ? "?" : v.CodecName.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeVideoProfile(MediaStreamInfo v)
|
||||
{
|
||||
var profile = (v.Profile ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(profile))
|
||||
{
|
||||
if ((v.PixelFormat ?? string.Empty).Contains("10", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return " (10-bit)";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (profile.Contains("high 10", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return " (Hi10p)";
|
||||
}
|
||||
|
||||
return $" ({profile})";
|
||||
}
|
||||
|
||||
private static string NormalizeAudioCodec(string? codec)
|
||||
{
|
||||
var c = (codec ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return c switch
|
||||
{
|
||||
"aac" => "AAC",
|
||||
"ac3" => "AC3",
|
||||
"eac3" => "EAC3",
|
||||
"dts" => "DTS",
|
||||
"flac" => "FLAC",
|
||||
"mp3" => "MP3",
|
||||
"mp2" => "MP2",
|
||||
"opus" => "OPUS",
|
||||
_ => string.IsNullOrWhiteSpace(codec) ? "?" : codec.ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeSubtitleCodec(string? codec)
|
||||
{
|
||||
var c = (codec ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return c switch
|
||||
{
|
||||
"subrip" => "SRT",
|
||||
"ass" => "ASS",
|
||||
"ssa" => "SSA",
|
||||
"hdmv_pgs_subtitle" => "PGS",
|
||||
"dvd_subtitle" => "VobSub",
|
||||
"mov_text" => "MOV_TEXT",
|
||||
_ => string.IsNullOrWhiteSpace(codec) ? "?" : codec.ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeSubtitleCodecByExtension(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".srt" => "SRT",
|
||||
".ass" => "ASS",
|
||||
".ssa" => "SSA",
|
||||
".sup" => "PGS",
|
||||
".sub" or ".idx" => "VobSub",
|
||||
".vtt" => "WEBVTT",
|
||||
_ => ext.TrimStart('.').ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatBitrate(long? bps)
|
||||
{
|
||||
if (bps is not { } value || value <= 0)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
var kbps = value / 1000d;
|
||||
if (kbps >= 10000)
|
||||
{
|
||||
return $"{FormatDecimal(kbps / 1000d)} Mbps";
|
||||
}
|
||||
|
||||
return $"{FormatDecimal(kbps)} kbps";
|
||||
}
|
||||
|
||||
private static string FormatSampleRate(int? sampleRateHz) => sampleRateHz is { } hz && hz > 0 ? $"{hz} Hz" : "? Hz";
|
||||
|
||||
private static string FormatFps(double? fps) => fps is { } value && value > 0 ? $"{FormatDecimal(value)} fps" : "? fps";
|
||||
|
||||
private static string FormatDecimal(double value) =>
|
||||
value.ToString("0.###", CultureInfo.GetCultureInfo("ru-RU"));
|
||||
|
||||
private static bool IsFontAttachment(MediaStreamInfo stream)
|
||||
{
|
||||
if (stream.Kind != MediaStreamKind.Attachment)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mime = (stream.AttachmentDeclaredMimeType ?? string.Empty).ToLowerInvariant();
|
||||
if (mime.Contains("font", StringComparison.Ordinal)
|
||||
|| mime.Contains("truetype", StringComparison.Ordinal)
|
||||
|| mime.Contains("opentype", StringComparison.Ordinal)
|
||||
|| mime.Contains("sfnt", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = stream.AttachmentDeclaredFileName ?? string.Empty;
|
||||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||||
return ext is ".ttf" or ".otf" or ".ttc" or ".otc";
|
||||
}
|
||||
}
|
||||
211
EmbyToolbox/Themes/UiColors.xaml
Normal file
211
EmbyToolbox/Themes/UiColors.xaml
Normal file
@ -0,0 +1,211 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- Cursor / VS Code "Light+ / Light Modern" inspired — нейтральный workbench, белые панели, #0078D4 accent -->
|
||||
<Color x:Key="C.Workbench">#F3F3F3</Color>
|
||||
<Color x:Key="C.Editor">#FFFFFFFF</Color>
|
||||
<Color x:Key="C.Sidebar">#EAEAEA</Color>
|
||||
<Color x:Key="C.Border">#D4D4D4</Color>
|
||||
<Color x:Key="C.BorderSubtle">#E1E1E1</Color>
|
||||
<Color x:Key="C.Text">#1A1A1A</Color>
|
||||
<Color x:Key="C.Muted">#6B6B6B</Color>
|
||||
<Color x:Key="C.Caption">#6E6E6E</Color>
|
||||
<Color x:Key="C.Placeholder">#8C8C8C</Color>
|
||||
<Color x:Key="C.TextDisabled">#A8A8A8</Color>
|
||||
<Color x:Key="C.Accent">#0078D4</Color>
|
||||
<Color x:Key="C.AccentHover">#006CBD</Color>
|
||||
<Color x:Key="C.AccentPressed">#005A9E</Color>
|
||||
<Color x:Key="C.DangerText">#A1260D</Color>
|
||||
<Color x:Key="C.DangerBorder">#E0B0A8</Color>
|
||||
<Color x:Key="C.DangerBackground">#FFF4F2</Color>
|
||||
<Color x:Key="C.DangerBgHover">#FDECE8</Color>
|
||||
<Color x:Key="C.ErrorText">#A1260D</Color>
|
||||
<Color x:Key="C.Success">#0B6E2F</Color>
|
||||
<!-- Фон строки очереди «Готово» и заливка общего ProgressBar конвертации -->
|
||||
<Color x:Key="C.QueueRowDone">#DDF5DD</Color>
|
||||
<Color x:Key="C.DataHeader">#F0F0F0</Color>
|
||||
<Color x:Key="C.DataRowA">#FFFFFFFF</Color>
|
||||
<Color x:Key="C.DataRowB">#FAFAFA</Color>
|
||||
<Color x:Key="C.DataRowHover">#E8E8E8</Color>
|
||||
<Color x:Key="C.DataRowSelected">#D6E8FC</Color>
|
||||
<Color x:Key="C.DataRowSelectHover">#B0D0F2</Color>
|
||||
<Color x:Key="C.DataLine">#E1E1E1</Color>
|
||||
<Color x:Key="C.LogBg">#F5F5F5</Color>
|
||||
<Color x:Key="C.LogBorder">#D4D4D4</Color>
|
||||
<Color x:Key="C.LogText">#1E1E1E</Color>
|
||||
<Color x:Key="C.Toolbar">#ECECEC</Color>
|
||||
<Color x:Key="C.Status">#EAEAEA</Color>
|
||||
<Color x:Key="C.TabStrip">#F0F0F0</Color>
|
||||
<Color x:Key="C.TabHover">#E0E0E0</Color>
|
||||
<Color x:Key="C.Track">#E5E5E5</Color>
|
||||
<!-- Как в Visual Studio: вторичные кнопки, scroll/slider, hover полей -->
|
||||
<Color x:Key="C.ButtonHover">#E1E1E1</Color>
|
||||
<Color x:Key="C.ButtonPressed">#C8C8C8</Color>
|
||||
<!-- Единая палитра кнопок (Primary / Secondary / Danger / Ghost) -->
|
||||
<Color x:Key="C.Btn.Primary">#2D7DFF</Color>
|
||||
<Color x:Key="C.Btn.PrimaryHover">#1F6FE5</Color>
|
||||
<Color x:Key="C.Btn.PrimaryPressed">#1856CC</Color>
|
||||
<Color x:Key="C.Btn.SecondaryBg">#F5F5F5</Color>
|
||||
<Color x:Key="C.Btn.SecondaryBorder">#CCCCCC</Color>
|
||||
<Color x:Key="C.Btn.SecondaryHover">#EAEAEA</Color>
|
||||
<Color x:Key="C.Btn.SecondaryPressed">#E3E3E3</Color>
|
||||
<Color x:Key="C.Btn.DangerHover">#FFE5E5</Color>
|
||||
<Color x:Key="C.Btn.DangerPressed">#FFDADA</Color>
|
||||
<Color x:Key="C.Btn.GhostBorder">#DDDDDD</Color>
|
||||
<Color x:Key="C.Btn.GhostHover">#F0F0F0</Color>
|
||||
<Color x:Key="C.ControlHover">#E8E8E8</Color>
|
||||
<Color x:Key="C.DangerPressed">#E0B0B0</Color>
|
||||
<Color x:Key="C.ScrollBarThumb">#A6A6A6</Color>
|
||||
<Color x:Key="C.ScrollBarThumbHover">#7A7A7A</Color>
|
||||
<Color x:Key="C.ScrollBarTrack">#F0F0F0</Color>
|
||||
<Color x:Key="C.SliderThumb">#2C2C2C</Color>
|
||||
<Color x:Key="C.InputBackground">#FFFFFFFF</Color>
|
||||
<Color x:Key="C.InputBorder">#D4D4D4</Color>
|
||||
<Color x:Key="C.SelectedText">#FFFFFF</Color>
|
||||
<Color x:Key="C.InputDisabledBg">#F0F0F0</Color>
|
||||
<Color x:Key="C.ComboHoverBg">#FAFAFA</Color>
|
||||
<Color x:Key="C.QueueDropOverlay">#0D2D7CE5</Color>
|
||||
<Color x:Key="C.MergeDropOverlay">#1A0B79FF</Color>
|
||||
<Color x:Key="C.QueueRunning">#D9ECFF</Color>
|
||||
<Color x:Key="C.QueueRunningBorder">#5A8FD8</Color>
|
||||
<Color x:Key="C.QueueCopying">#E8D9FF</Color>
|
||||
<Color x:Key="C.QueueReplacing">#FFE8CC</Color>
|
||||
<Color x:Key="C.QueueError">#FFD9D9</Color>
|
||||
<Color x:Key="C.QueueCancelled">#ECECEC</Color>
|
||||
<Color x:Key="C.PlanNonSkipText">#7A4E1D</Color>
|
||||
<Color x:Key="C.PlanSkipText">#1A1A1A</Color>
|
||||
<Color x:Key="C.Track.DefaultBg">#EAF6EA</Color>
|
||||
<Color x:Key="C.Track.ConvertBg">#FFF7D6</Color>
|
||||
<Color x:Key="C.Track.RemoveBg">#FBE3E6</Color>
|
||||
<Color x:Key="C.Track.NormalBg">#FFFFFF</Color>
|
||||
<Color x:Key="C.Track.HoverDefault">#E3F0E3</Color>
|
||||
<Color x:Key="C.Track.HoverConvert">#FFF2CC</Color>
|
||||
<Color x:Key="C.Track.HoverRemove">#F7D8DC</Color>
|
||||
<Color x:Key="C.Track.SelDefault">#DEEBDE</Color>
|
||||
<Color x:Key="C.Track.SelConvert">#FFECC2</Color>
|
||||
<Color x:Key="C.Track.SelRemove">#F2D2D7</Color>
|
||||
<Color x:Key="C.Track.SelNormal">#ECEEF1</Color>
|
||||
<Color x:Key="C.Track.SelHoverDefault">#D6E5D6</Color>
|
||||
<Color x:Key="C.Track.SelHoverConvert">#FFE6B0</Color>
|
||||
<Color x:Key="C.Track.SelHoverRemove">#EEC8CE</Color>
|
||||
<Color x:Key="C.Track.SelHoverNormal">#E3E5E9</Color>
|
||||
<Color x:Key="C.Track.SelectionStripe">#B0B8C1</Color>
|
||||
<Color x:Key="C.Track.ConflictBorder">#D57A80</Color>
|
||||
<Color x:Key="C.Toast.SuccessBg">#EAF6EA</Color>
|
||||
<Color x:Key="C.Toast.SuccessBorder">#4CAF50</Color>
|
||||
<Color x:Key="C.Toast.SuccessIcon">#388E3C</Color>
|
||||
<Color x:Key="C.Toast.WarningBg">#FFF4CC</Color>
|
||||
<Color x:Key="C.Toast.WarningBorder">#E0A800</Color>
|
||||
<Color x:Key="C.Toast.WarningIcon">#BF8F00</Color>
|
||||
<Color x:Key="C.Toast.ErrorBg">#FDECEC</Color>
|
||||
<Color x:Key="C.Toast.ErrorBorder">#D9534F</Color>
|
||||
<Color x:Key="C.Toast.ErrorIcon">#D9534F</Color>
|
||||
<Color x:Key="C.LogDebug">#777777</Color>
|
||||
<Color x:Key="C.LogInfo">#000000</Color>
|
||||
<Color x:Key="C.LogWarning">#B26A00</Color>
|
||||
<Color x:Key="C.LogError">#C62828</Color>
|
||||
<Color x:Key="C.LogSelectionBg">#0078D7</Color>
|
||||
<Color x:Key="C.LogSelectionText">#FFFFFF</Color>
|
||||
|
||||
<SolidColorBrush x:Key="Ui.Brush.Window" Color="{StaticResource C.Workbench}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Surface" Color="{StaticResource C.Editor}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.SurfaceSubtle" Color="{StaticResource C.TabStrip}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Border" Color="{StaticResource C.Border}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.BorderSubtle" Color="{StaticResource C.BorderSubtle}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Text" Color="{StaticResource C.Text}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Muted" Color="{StaticResource C.Muted}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Caption" Color="{StaticResource C.Caption}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Placeholder" Color="{StaticResource C.Placeholder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.TextDisabled" Color="{StaticResource C.TextDisabled}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Accent" Color="{StaticResource C.Accent}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.AccentHover" Color="{StaticResource C.AccentHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.AccentPressed" Color="{StaticResource C.AccentPressed}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DangerText" Color="{StaticResource C.DangerText}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DangerBorder" Color="{StaticResource C.DangerBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DangerBackground" Color="{StaticResource C.DangerBackground}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DangerBgHover" Color="{StaticResource C.DangerBgHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ErrorText" Color="{StaticResource C.ErrorText}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Success" Color="{StaticResource C.Success}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueRowDone" Color="{StaticResource C.QueueRowDone}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DataHeader" Color="{StaticResource C.DataHeader}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DataRowA" Color="{StaticResource C.DataRowA}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DataRowB" Color="{StaticResource C.DataRowB}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DataRowHover" Color="{StaticResource C.DataRowHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DataRowSelected" Color="{StaticResource C.DataRowSelected}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DataRowSelectHover" Color="{StaticResource C.DataRowSelectHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DataLine" Color="{StaticResource C.DataLine}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogBg" Color="{StaticResource C.LogBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogBorder" Color="{StaticResource C.LogBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogText" Color="{StaticResource C.LogText}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toolbar" Color="{StaticResource C.Toolbar}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.StatusBar" Color="{StaticResource C.Status}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.TabStrip" Color="{StaticResource C.TabStrip}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.TabHover" Color="{StaticResource C.TabHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track" Color="{StaticResource C.Track}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Transparent" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ButtonHover" Color="{StaticResource C.ButtonHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ButtonPressed" Color="{StaticResource C.ButtonPressed}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.Primary" Color="{StaticResource C.Btn.Primary}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.PrimaryHover" Color="{StaticResource C.Btn.PrimaryHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.PrimaryPressed" Color="{StaticResource C.Btn.PrimaryPressed}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryBg" Color="{StaticResource C.Btn.SecondaryBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryBorder" Color="{StaticResource C.Btn.SecondaryBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryHover" Color="{StaticResource C.Btn.SecondaryHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.SecondaryPressed" Color="{StaticResource C.Btn.SecondaryPressed}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.DangerHover" Color="{StaticResource C.Btn.DangerHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.DangerPressed" Color="{StaticResource C.Btn.DangerPressed}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.GhostBorder" Color="{StaticResource C.Btn.GhostBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Btn.GhostHover" Color="{StaticResource C.Btn.GhostHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ControlHover" Color="{StaticResource C.ControlHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.DangerPressed" Color="{StaticResource C.DangerPressed}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ScrollBarThumb" Color="{StaticResource C.ScrollBarThumb}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ScrollBarThumbHover" Color="{StaticResource C.ScrollBarThumbHover}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ScrollBarTrack" Color="{StaticResource C.ScrollBarTrack}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.SliderThumb" Color="{StaticResource C.SliderThumb}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.InputBackground" Color="{StaticResource C.InputBackground}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.InputBorder" Color="{StaticResource C.InputBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.SelectedText" Color="{StaticResource C.SelectedText}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.InputDisabledBg" Color="{StaticResource C.InputDisabledBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.ComboHoverBg" Color="{StaticResource C.ComboHoverBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueDropOverlay" Color="{StaticResource C.QueueDropOverlay}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.MergeDropOverlay" Color="{StaticResource C.MergeDropOverlay}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueRunning" Color="{StaticResource C.QueueRunning}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueRunningBorder" Color="{StaticResource C.QueueRunningBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueCopying" Color="{StaticResource C.QueueCopying}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueReplacing" Color="{StaticResource C.QueueReplacing}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueError" Color="{StaticResource C.QueueError}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.QueueCancelled" Color="{StaticResource C.QueueCancelled}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.PlanNonSkipText" Color="{StaticResource C.PlanNonSkipText}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.PlanSkipText" Color="{StaticResource C.PlanSkipText}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.DefaultBg" Color="{StaticResource C.Track.DefaultBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.ConvertBg" Color="{StaticResource C.Track.ConvertBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.RemoveBg" Color="{StaticResource C.Track.RemoveBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.NormalBg" Color="{StaticResource C.Track.NormalBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.HoverDefault" Color="{StaticResource C.Track.HoverDefault}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.HoverConvert" Color="{StaticResource C.Track.HoverConvert}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.HoverRemove" Color="{StaticResource C.Track.HoverRemove}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelDefault" Color="{StaticResource C.Track.SelDefault}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelConvert" Color="{StaticResource C.Track.SelConvert}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelRemove" Color="{StaticResource C.Track.SelRemove}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelNormal" Color="{StaticResource C.Track.SelNormal}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverDefault" Color="{StaticResource C.Track.SelHoverDefault}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverConvert" Color="{StaticResource C.Track.SelHoverConvert}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverRemove" Color="{StaticResource C.Track.SelHoverRemove}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelHoverNormal" Color="{StaticResource C.Track.SelHoverNormal}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.SelectionStripe" Color="{StaticResource C.Track.SelectionStripe}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Track.ConflictBorder" Color="{StaticResource C.Track.ConflictBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.SuccessBg" Color="{StaticResource C.Toast.SuccessBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.SuccessBorder" Color="{StaticResource C.Toast.SuccessBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.SuccessIcon" Color="{StaticResource C.Toast.SuccessIcon}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.WarningBg" Color="{StaticResource C.Toast.WarningBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.WarningBorder" Color="{StaticResource C.Toast.WarningBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.WarningIcon" Color="{StaticResource C.Toast.WarningIcon}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.ErrorBg" Color="{StaticResource C.Toast.ErrorBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.ErrorBorder" Color="{StaticResource C.Toast.ErrorBorder}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.Toast.ErrorIcon" Color="{StaticResource C.Toast.ErrorIcon}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogDebug" Color="{StaticResource C.LogDebug}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogInfo" Color="{StaticResource C.LogInfo}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogWarning" Color="{StaticResource C.LogWarning}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogError" Color="{StaticResource C.LogError}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogSelectionBg" Color="{StaticResource C.LogSelectionBg}" />
|
||||
<SolidColorBrush x:Key="Ui.Brush.LogSelectionText" Color="{StaticResource C.LogSelectionText}" />
|
||||
</ResourceDictionary>
|
||||
942
EmbyToolbox/Themes/UiComponentStyles.xaml
Normal file
942
EmbyToolbox/Themes/UiComponentStyles.xaml
Normal file
@ -0,0 +1,942 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:System.Windows.Controls;assembly=PresentationFramework">
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="UiLayout.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Window}" />
|
||||
<Setter Property="UseLayoutRounding" Value="True" />
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiTextH1" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="LineHeight" Value="26" />
|
||||
</Style>
|
||||
<Style x:Key="UiTextH2" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="LineHeight" Value="20" />
|
||||
</Style>
|
||||
<Style x:Key="UiTextBody" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Style>
|
||||
<Style x:Key="UiTextCaption" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="LineHeight" Value="15" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Caption}" />
|
||||
</Style>
|
||||
<Style x:Key="UiTextMuted" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
|
||||
</Style>
|
||||
<Style x:Key="UiTextError" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.ErrorText}" />
|
||||
</Style>
|
||||
<Style x:Key="UiTextSuccess" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Success}" />
|
||||
</Style>
|
||||
|
||||
<!-- Базовый цвет для "обычных" TextBlock/Label без явного стиля -->
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Style>
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiSectionCard" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.SurfaceSubtle}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="14,12" />
|
||||
</Style>
|
||||
|
||||
<!-- Primary: основное действие -->
|
||||
<Style x:Key="UiButtonPrimary" TargetType="Button">
|
||||
<Style.Resources>
|
||||
<Style TargetType="AccessText">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
</Style.Resources>
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="TextElement.Foreground" Value="White" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Padding" Value="10,2" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
|
||||
CornerRadius="6" SnapsToDevicePixels="True">
|
||||
<Border x:Name="Bd" CornerRadius="5" Margin="0" SnapsToDevicePixels="True" BorderThickness="0"
|
||||
Background="{DynamicResource Ui.Brush.Btn.Primary}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
RecognizesAccessKey="True"
|
||||
TextElement.Foreground="White" />
|
||||
</Border>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
<Condition Property="IsEnabled" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.PrimaryHover}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.PrimaryPressed}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Secondary -->
|
||||
<Style x:Key="UiButtonSecondary" TargetType="Button">
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Padding" Value="10,2" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
|
||||
CornerRadius="6" SnapsToDevicePixels="True">
|
||||
<Border x:Name="Bd" CornerRadius="5" SnapsToDevicePixels="True"
|
||||
BorderThickness="1" BorderBrush="{DynamicResource Ui.Brush.Btn.SecondaryBorder}"
|
||||
Background="{DynamicResource Ui.Brush.Btn.SecondaryBg}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
RecognizesAccessKey="True" />
|
||||
</Border>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
<Condition Property="IsEnabled" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.SecondaryHover}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.SecondaryPressed}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Danger: как secondary, акцент на hover (опасное действие) -->
|
||||
<Style x:Key="UiButtonDanger" TargetType="Button">
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Padding" Value="10,2" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
|
||||
CornerRadius="6" SnapsToDevicePixels="True">
|
||||
<Border x:Name="Bd" CornerRadius="5" SnapsToDevicePixels="True"
|
||||
BorderThickness="1" BorderBrush="{DynamicResource Ui.Brush.Btn.SecondaryBorder}"
|
||||
Background="{DynamicResource Ui.Brush.Btn.SecondaryBg}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
RecognizesAccessKey="True" />
|
||||
</Border>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
<Condition Property="IsEnabled" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.DangerHover}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.DangerPressed}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Segoe MDL2 Assets: единый вид иконок (системные глифы Windows) -->
|
||||
<Style x:Key="UiMdlGlyphButton" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0,0,7,0" />
|
||||
</Style>
|
||||
<Style x:Key="UiMdlGlyphOnPrimary" TargetType="TextBlock" BasedOn="{StaticResource UiMdlGlyphButton}">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
<Style x:Key="UiMdlGlyphTab" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Caption}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0,0,7,0" />
|
||||
</Style>
|
||||
<Style x:Key="UiMdlGlyphTree" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0,0,7,0" />
|
||||
</Style>
|
||||
<Style x:Key="UiMdlGlyphEmpty" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Caption}" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Компактная кнопка только с Segoe MDL2 глифом -->
|
||||
<Style x:Key="UiButtonIconOnlySecondary" TargetType="Button" BasedOn="{StaticResource UiButtonSecondary}">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Width" Value="36" />
|
||||
<Setter Property="MinWidth" Value="36" />
|
||||
<Setter Property="MaxWidth" Value="36" />
|
||||
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="MaxHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiMdlGlyphIconOnlyMuted" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiSplitChevronMuted" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Muted}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="6,2,0,0" />
|
||||
</Style>
|
||||
|
||||
<!-- Ghost: второстепенные действия -->
|
||||
<Style x:Key="UiButtonGhost" TargetType="Button">
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Padding" Value="10,2" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="FocusRing" Background="Transparent" BorderThickness="2" BorderBrush="Transparent"
|
||||
CornerRadius="6" SnapsToDevicePixels="True">
|
||||
<Border x:Name="Bd" CornerRadius="5" SnapsToDevicePixels="True"
|
||||
BorderThickness="1" BorderBrush="{DynamicResource Ui.Brush.Btn.GhostBorder}"
|
||||
Background="Transparent">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
RecognizesAccessKey="True" />
|
||||
</Border>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
<Condition Property="IsEnabled" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.GhostHover}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.Btn.SecondaryHover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="FocusRing" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Btn.Primary}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- TextBox: без внутришаблонного Binding/плейсхолдера (избегаем BAML/триггеров TemplatedParent). Плейсхолдер — в UI отдельным TextBlock. -->
|
||||
<Style x:Key="UiTextInput" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="FieldRoot" CornerRadius="0" SnapsToDevicePixels="True" BorderThickness="1" Padding="0"
|
||||
BorderBrush="{DynamicResource Ui.Brush.InputBorder}" Background="{DynamicResource Ui.Brush.InputBackground}">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Focusable="false" VerticalContentAlignment="Center" Padding="{StaticResource Ui.InputPadding}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocusWithin" Value="True">
|
||||
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="FieldRoot" Property="Background" Value="{DynamicResource Ui.Brush.InputDisabledBg}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.TextDisabled}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="UiPathField" TargetType="TextBox" BasedOn="{StaticResource UiTextInput}">
|
||||
<Setter Property="IsReadOnly" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiPasswordInput" TargetType="PasswordBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="PasswordBox">
|
||||
<Border x:Name="FieldRoot" CornerRadius="0" SnapsToDevicePixels="True" BorderThickness="1"
|
||||
BorderBrush="{DynamicResource Ui.Brush.InputBorder}" Background="{DynamicResource Ui.Brush.InputBackground}">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Focusable="false" VerticalContentAlignment="Center" Padding="{StaticResource Ui.InputPadding}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocusWithin" Value="True">
|
||||
<Setter TargetName="FieldRoot" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="FieldRoot" Property="Background" Value="{DynamicResource Ui.Brush.InputDisabledBg}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.TextDisabled}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiComboItem" TargetType="ComboBoxItem">
|
||||
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="B" SnapsToDevicePixels="True" Padding="{TemplateBinding Padding}" Background="Transparent" BorderBrush="Transparent" BorderThickness="0">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="B" Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiCombo" TargetType="ComboBox">
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="ItemContainerStyle" Value="{StaticResource UiComboItem}" />
|
||||
<Setter Property="IsEditable" Value="False" />
|
||||
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid SnapsToDevicePixels="True">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" MinWidth="0" />
|
||||
<ColumnDefinition Width="22" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="Bd" Grid.ColumnSpan="2" Panel.ZIndex="0" SnapsToDevicePixels="True" BorderBrush="{DynamicResource Ui.Brush.InputBorder}" BorderThickness="1" Background="{DynamicResource Ui.Brush.InputBackground}" />
|
||||
<ToggleButton x:Name="OpenHit" Grid.ColumnSpan="2" Panel.ZIndex="1" Focusable="False" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" TabIndex="0"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border Background="Transparent" SnapsToDevicePixels="True" />
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="ContentSite" Grid.Column="0" Panel.ZIndex="2" IsHitTestVisible="False" Margin="8,4,6,4" VerticalAlignment="Center" HorizontalAlignment="Left" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
|
||||
Content="{TemplateBinding SelectionBoxItem}" ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}" ContentTemplate="{TemplateBinding ItemTemplate}" ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" />
|
||||
<Path x:Name="Arrow" Grid.Column="1" Panel.ZIndex="2" IsHitTestVisible="False" VerticalAlignment="Center" HorizontalAlignment="Center" SnapsToDevicePixels="True" Fill="{DynamicResource Ui.Brush.Muted}" Data="M0,0 L3.2,2.2 L6.2,0 Z" />
|
||||
</Grid>
|
||||
<Popup x:Name="PART_Popup" AllowsTransparency="True" StaysOpen="False" IsOpen="{TemplateBinding IsDropDownOpen}" Focusable="False" Placement="Bottom" VerticalOffset="0" HorizontalOffset="0" PopupAnimation="None" PlacementTarget="{Binding ElementName=Bd}" SnapsToDevicePixels="True">
|
||||
<Border MinWidth="{Binding ActualWidth, ElementName=Bd, Mode=OneWay}" MaxHeight="320" SnapsToDevicePixels="True" BorderBrush="{DynamicResource Ui.Brush.Border}" BorderThickness="1" Background="{DynamicResource Ui.Brush.SurfaceSubtle}">
|
||||
<ScrollViewer CanContentScroll="True" MinHeight="1" MinWidth="0" MaxHeight="320" BorderThickness="0" Padding="0">
|
||||
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" KeyboardNavigation.DirectionalNavigation="Contained" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ui.Brush.ComboHoverBg}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsDropDownOpen" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsDropDownOpen" Value="False" />
|
||||
<Condition Property="IsKeyboardFocusWithin" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
</MultiTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiDatePicker" TargetType="DatePicker">
|
||||
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.InputBackground}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.InputBorder}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
</Style>
|
||||
|
||||
<!-- CheckBox: modern box 18, check + stroke -->
|
||||
<Style x:Key="UiCheckBox" TargetType="CheckBox">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Viewbox Width="18" Height="18" SnapsToDevicePixels="True" VerticalAlignment="Center">
|
||||
<Grid Width="18" Height="18">
|
||||
<Border x:Name="Box" CornerRadius="0" BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}" BorderThickness="1" Background="{DynamicResource Ui.Brush.Surface}" />
|
||||
<Path x:Name="Mark" Data="M 3.2,8.2 L 6.2,11.1 12.1,3.1" Stroke="White" StrokeThickness="1.6" StrokeLineJoin="Round" Visibility="Collapsed" />
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
<ContentPresenter Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
<Setter TargetName="Mark" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
<Condition Property="IsChecked" Value="False" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Ui.Brush.ControlHover}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiRadio" TargetType="RadioButton">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RadioButton">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Viewbox Width="18" Height="18">
|
||||
<Grid Width="18" Height="18">
|
||||
<Border x:Name="Outer" CornerRadius="9" BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}" BorderThickness="1" Background="{DynamicResource Ui.Brush.Surface}" />
|
||||
<Ellipse x:Name="Inner" Width="8" Height="8" Fill="{DynamicResource Ui.Brush.Accent}" Opacity="0" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
<ContentPresenter Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Inner" Property="Opacity" Value="1" />
|
||||
<Setter TargetName="Outer" Property="BorderBrush" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
<Condition Property="IsChecked" Value="False" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="Outer" Property="Background" Value="{DynamicResource Ui.Brush.ControlHover}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="RadioButton" BasedOn="{StaticResource UiRadio}" />
|
||||
<Style TargetType="CheckBox" BasedOn="{StaticResource UiCheckBox}" />
|
||||
|
||||
<Style x:Key="UiContextMenu" TargetType="ContextMenu">
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.SurfaceSubtle}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="2" />
|
||||
</Style>
|
||||
<Style TargetType="ContextMenu" BasedOn="{StaticResource UiContextMenu}" />
|
||||
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.TextDisabled}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiDataGridTextElement" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
<Style x:Key="UiDataGridTextEdit" TargetType="TextBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiDataGrid" TargetType="DataGrid">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Surface}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="RowBackground" Value="{DynamicResource Ui.Brush.DataRowA}" />
|
||||
<Setter Property="AlternatingRowBackground" Value="{DynamicResource Ui.Brush.DataRowB}" />
|
||||
<Setter Property="RowHeight" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HeadersVisibility" Value="Column" />
|
||||
<Setter Property="CanUserAddRows" Value="False" />
|
||||
<Setter Property="AutoGenerateColumns" Value="False" />
|
||||
<Setter Property="SelectionMode" Value="Single" />
|
||||
<Setter Property="SelectionUnit" Value="FullRow" />
|
||||
<Setter Property="RowHeaderWidth" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiDataGridColumnHeader" TargetType="DataGridColumnHeader">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Height" Value="{StaticResource Ui.BaseControlHeight}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataHeader}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.DataLine}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,1,1" />
|
||||
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiDataGridCell" TargetType="DataGridCell">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Padding" Value="{StaticResource Ui.DataGridCellPadding}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="DataGridCell">
|
||||
<Border x:Name="Cbd" Padding="{TemplateBinding Padding}" Background="Transparent" SnapsToDevicePixels="True">
|
||||
<ContentPresenter x:Name="ContentSite" VerticalAlignment="Center" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="True" />
|
||||
<Condition Property="Selector.IsSelectionActive" Value="False" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</MultiTrigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="True" />
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</MultiTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiDataGridRow" TargetType="DataGridRow">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Style.Triggers>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="False" />
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowHover}" />
|
||||
</MultiTrigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="True" />
|
||||
<Condition Property="IsMouseOver" Value="False" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
|
||||
</MultiTrigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="True" />
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelectHover}" />
|
||||
</MultiTrigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiTabItem" TargetType="TabItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="B" SnapsToDevicePixels="True" BorderThickness="0" Padding="12,0" MinHeight="32" BorderBrush="Transparent" Background="Transparent" Margin="0,0,1,0">
|
||||
<Grid MinHeight="32">
|
||||
<ContentPresenter x:Name="C" ContentSource="Header" VerticalAlignment="Center" TextElement.Foreground="{DynamicResource Ui.Brush.Caption}" />
|
||||
<Border x:Name="Underline" Height="2" VerticalAlignment="Bottom" Background="Transparent" SnapsToDevicePixels="True" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="C" Property="TextElement.Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter TargetName="Underline" Property="Background" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="True" />
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="B" Property="Background" Value="{DynamicResource Ui.Brush.DataHeader}" />
|
||||
</MultiTrigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="False" />
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="B" Property="Background" Value="{DynamicResource Ui.Brush.TabHover}" />
|
||||
</MultiTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiTabControl" TargetType="TabControl">
|
||||
<Setter Property="ItemContainerStyle" Value="{StaticResource UiTabItem}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Border Grid.Row="0" Background="{DynamicResource Ui.Brush.TabStrip}" BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}" BorderThickness="0,0,0,1" Padding="8,0,0,0" SnapsToDevicePixels="True">
|
||||
<TabPanel x:Name="HeaderPanel" IsItemsHost="True" />
|
||||
</Border>
|
||||
<Border Grid.Row="1" Background="{DynamicResource Ui.Brush.Surface}" BorderBrush="{DynamicResource Ui.Brush.Border}" BorderThickness="1" Padding="10,8" SnapsToDevicePixels="True">
|
||||
<ContentPresenter x:Name="PART_SelectedContentHost" ContentSource="SelectedContent" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiProgressBar" TargetType="ProgressBar">
|
||||
<Setter Property="Height" Value="3" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Accent}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Track}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiSliderRepeat" TargetType="RepeatButton">
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Height" Value="20" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Background="Transparent" SnapsToDevicePixels="True" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiScrollBarThumb" TargetType="Thumb">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border x:Name="ThumbBorder"
|
||||
Background="{DynamicResource Ui.Brush.ScrollBarThumb}"
|
||||
BorderBrush="{DynamicResource Ui.Brush.Border}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="2"
|
||||
SnapsToDevicePixels="True" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ThumbBorder" Property="Background" Value="{DynamicResource Ui.Brush.ScrollBarThumbHover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsDragging" Value="True">
|
||||
<Setter TargetName="ThumbBorder" Property="Background" Value="{DynamicResource Ui.Brush.Muted}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiScrollBarButton" TargetType="RepeatButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Background="{DynamicResource Ui.Brush.ScrollBarTrack}" SnapsToDevicePixels="True" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.ScrollBarTrack}" />
|
||||
<Setter Property="Width" Value="12" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid Background="{TemplateBinding Background}">
|
||||
<Track x:Name="PART_Track" IsDirectionReversed="True">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton x:Name="PART_DecreaseButton"
|
||||
Command="ScrollBar.LineUpCommand"
|
||||
Style="{DynamicResource UiScrollBarButton}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{DynamicResource UiScrollBarThumb}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton x:Name="PART_IncreaseButton"
|
||||
Command="ScrollBar.LineDownCommand"
|
||||
Style="{DynamicResource UiScrollBarButton}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter Property="Height" Value="12" />
|
||||
<Setter Property="Width" Value="Auto" />
|
||||
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False" />
|
||||
<Setter TargetName="PART_DecreaseButton" Property="Command" Value="ScrollBar.LineLeftCommand" />
|
||||
<Setter TargetName="PART_IncreaseButton" Property="Command" Value="ScrollBar.LineRightCommand" />
|
||||
</Trigger>
|
||||
<Trigger Property="Orientation" Value="Vertical">
|
||||
<Setter Property="Width" Value="12" />
|
||||
<Setter Property="Height" Value="Auto" />
|
||||
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="True" />
|
||||
<Setter TargetName="PART_DecreaseButton" Property="Command" Value="ScrollBar.LineUpCommand" />
|
||||
<Setter TargetName="PART_IncreaseButton" Property="Command" Value="ScrollBar.LineDownCommand" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiSlider" TargetType="Slider">
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Height" Value="20" />
|
||||
<Setter Property="IsSnapToTickEnabled" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid SnapsToDevicePixels="True">
|
||||
<Border x:Name="TrackB" Height="3" VerticalAlignment="Center" SnapsToDevicePixels="True" Background="{DynamicResource Ui.Brush.Track}" />
|
||||
<Track x:Name="PART_Track" VerticalAlignment="Center">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="{x:Static controls:Slider.DecreaseLarge}" Style="{StaticResource UiSliderRepeat}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Width="7" Height="16" SnapsToDevicePixels="True" Focusable="True">
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border x:Name="T" Background="{DynamicResource Ui.Brush.SliderThumb}" SnapsToDevicePixels="True" BorderBrush="{DynamicResource Ui.Brush.Border}" BorderThickness="1" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="T" Property="Background" Value="{DynamicResource Ui.Brush.Muted}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsDragging" Value="True">
|
||||
<Setter TargetName="T" Property="Background" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="{x:Static controls:Slider.IncreaseLarge}" Style="{StaticResource UiSliderRepeat}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiLog" TargetType="TextBox" BasedOn="{x:Null}">
|
||||
<Setter Property="FontFamily" Value="Cascadia Mono,Consolas" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.LogText}" />
|
||||
<Setter Property="IsReadOnly" Value="True" />
|
||||
<Setter Property="TextWrapping" Value="NoWrap" />
|
||||
<Setter Property="AcceptsReturn" Value="True" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
|
||||
<Setter Property="MinHeight" Value="100" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="Lg" SnapsToDevicePixels="True" CornerRadius="0" BorderBrush="{DynamicResource Ui.Brush.LogBorder}" BorderThickness="1" Background="{DynamicResource Ui.Brush.LogBg}" Padding="6,4">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Focusable="false" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiToolBar" TargetType="ToolBar">
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.Toolbar}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="Padding" Value="8,0" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="UiStatusBar" TargetType="StatusBar">
|
||||
<Setter Property="Background" Value="{DynamicResource Ui.Brush.StatusBar}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Height" Value="22" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
|
||||
27
EmbyToolbox/Themes/UiLayout.xaml
Normal file
27
EmbyToolbox/Themes/UiLayout.xaml
Normal file
@ -0,0 +1,27 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
|
||||
<Thickness x:Key="Ui.Space.4">0,0,0,4</Thickness>
|
||||
<Thickness x:Key="Ui.Space.8">0,0,0,8</Thickness>
|
||||
<Thickness x:Key="Ui.Space.12">0,0,0,12</Thickness>
|
||||
<Thickness x:Key="Ui.Space.16">0,0,0,16</Thickness>
|
||||
<Thickness x:Key="Ui.Space.24">0,0,0,24</Thickness>
|
||||
<Thickness x:Key="Ui.SectionGap">0,0,0,20</Thickness>
|
||||
<Thickness x:Key="Ui.PagePadding">20,16,20,24</Thickness>
|
||||
<Thickness x:Key="Ui.CardPadding">20</Thickness>
|
||||
<Thickness x:Key="Ui.HStack.8">0,0,8,0</Thickness>
|
||||
<!-- Кнопки, TextBox, ComboBox форм — одна высота -->
|
||||
<sys:Double x:Key="Ui.BaseControlHeight">32</sys:Double>
|
||||
<!-- Внутренние отступы полей ввода (PART_ContentHost / ScrollViewer) -->
|
||||
<Thickness x:Key="Ui.InputPadding">8,4,8,4</Thickness>
|
||||
<!-- Сетка форм: колонка подписей (для ColumnDefinition.Width) + числа для Width контролов -->
|
||||
<GridLength x:Key="Ui.FormLabelColumn">160</GridLength>
|
||||
<sys:Double x:Key="Ui.FormLabelColumnWidth">160</sys:Double>
|
||||
<sys:Double x:Key="Ui.InputWidthSmall">200</sys:Double>
|
||||
<sys:Double x:Key="Ui.InputWidthMedium">360</sys:Double>
|
||||
<sys:Double x:Key="Ui.InputWidthLarge">480</sys:Double>
|
||||
<!-- DataGrid: горизонтальные вступы, вертикаль — Center -->
|
||||
<Thickness x:Key="Ui.DataGridCellPadding">6,0,6,0</Thickness>
|
||||
<sys:Double x:Key="Ui.Radius.4">4</sys:Double>
|
||||
<sys:Double x:Key="Ui.Radius.6">6</sys:Double>
|
||||
</ResourceDictionary>
|
||||
BIN
EmbyToolbox/Tools/ffmpeg.exe
Normal file
BIN
EmbyToolbox/Tools/ffmpeg.exe
Normal file
Binary file not shown.
BIN
EmbyToolbox/Tools/ffprobe.exe
Normal file
BIN
EmbyToolbox/Tools/ffprobe.exe
Normal file
Binary file not shown.
81
EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs
Normal file
81
EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using EmbyToolbox.Models;
|
||||
|
||||
namespace EmbyToolbox.ViewModels;
|
||||
|
||||
public sealed class AddFilesOptionsViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private readonly Action<AddFilesOptions> _onAdd;
|
||||
private readonly Action _onCancel;
|
||||
|
||||
private bool _removeForeignAudioAndSubtitles;
|
||||
|
||||
public AddFilesOptionsViewModel(Action<AddFilesOptions> onAdd, Action onCancel)
|
||||
{
|
||||
_onAdd = onAdd;
|
||||
_onCancel = onCancel;
|
||||
AddCommand = new RelayCommand(ExecuteAdd);
|
||||
CancelCommand = new RelayCommand(() => _onCancel());
|
||||
}
|
||||
|
||||
/// <summary>Взаимоисключающие опции: два свойства, чтобы не использовать TwoWay на одно поле с конвертером (переполнение стека в WPF).</summary>
|
||||
public bool OptionKeepAllTracks
|
||||
{
|
||||
get => !_removeForeignAudioAndSubtitles;
|
||||
set
|
||||
{
|
||||
if (!value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_removeForeignAudioAndSubtitles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_removeForeignAudioAndSubtitles = false;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(OptionRemoveForeignTracks));
|
||||
}
|
||||
}
|
||||
|
||||
public bool OptionRemoveForeignTracks
|
||||
{
|
||||
get => _removeForeignAudioAndSubtitles;
|
||||
set
|
||||
{
|
||||
if (!value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_removeForeignAudioAndSubtitles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_removeForeignAudioAndSubtitles = true;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(OptionKeepAllTracks));
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand AddCommand { get; }
|
||||
public RelayCommand CancelCommand { get; }
|
||||
|
||||
private void ExecuteAdd()
|
||||
{
|
||||
_onAdd(
|
||||
new AddFilesOptions
|
||||
{
|
||||
RemoveForeignAudioAndSubtitles = _removeForeignAudioAndSubtitles
|
||||
});
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
128
EmbyToolbox/ViewModels/AnalysisProgressViewModel.cs
Normal file
128
EmbyToolbox/ViewModels/AnalysisProgressViewModel.cs
Normal file
@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace EmbyToolbox.ViewModels;
|
||||
|
||||
/// <summary>Компактный индикатор прогресса ffprobe-анализа батча файлов.</summary>
|
||||
public sealed class AnalysisProgressViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private readonly Action _onCancel;
|
||||
|
||||
private bool _isPanelVisible;
|
||||
private string _statusLine = string.Empty;
|
||||
private double _analysisPercent;
|
||||
private bool _canCancel = true;
|
||||
|
||||
public AnalysisProgressViewModel(Action onCancel)
|
||||
{
|
||||
_onCancel = onCancel;
|
||||
CancelCommand = new RelayCommand(ExecuteCancel, () => _canCancel && _isPanelVisible);
|
||||
}
|
||||
|
||||
public ICommand CancelCommand { get; }
|
||||
|
||||
public bool IsPanelVisible
|
||||
{
|
||||
get => _isPanelVisible;
|
||||
private set
|
||||
{
|
||||
if (_isPanelVisible == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPanelVisible = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(PanelVisibility));
|
||||
if (CancelCommand is RelayCommand rc)
|
||||
{
|
||||
rc.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Visibility PanelVisibility => _isPanelVisible ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public string StatusLine
|
||||
{
|
||||
get => _statusLine;
|
||||
private set
|
||||
{
|
||||
if (_statusLine == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_statusLine = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public double AnalysisPercent
|
||||
{
|
||||
get => _analysisPercent;
|
||||
private set
|
||||
{
|
||||
if (Math.Abs(_analysisPercent - value) < 0.01)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_analysisPercent = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void StartBatch(int total)
|
||||
{
|
||||
_canCancel = true;
|
||||
IsPanelVisible = true;
|
||||
StatusLine = total > 0 ? $"Анализ файлов: 0 из {total}" : "Анализ файлов";
|
||||
AnalysisPercent = 0;
|
||||
if (CancelCommand is RelayCommand rc)
|
||||
{
|
||||
rc.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnProgress(EmbyToolbox.Services.QueueAnalysisProgress p)
|
||||
{
|
||||
if (p.Total <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StatusLine = $"Анализ файлов: {p.Processed} из {p.Total}";
|
||||
AnalysisPercent = 100.0 * p.Processed / p.Total;
|
||||
}
|
||||
|
||||
public async Task FinalizeAndHideAsync(int errorCount)
|
||||
{
|
||||
_canCancel = false;
|
||||
if (CancelCommand is RelayCommand rc)
|
||||
{
|
||||
rc.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
StatusLine = $"Анализ завершен с ошибками: {errorCount}";
|
||||
AnalysisPercent = 100;
|
||||
await Task.Delay(2500).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
IsPanelVisible = false;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void ExecuteCancel() => _onCancel();
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user