Compare commits

..

10 Commits

Author SHA1 Message Date
Emby Toolbox
75bcdff3b1 Merge video info JSON viewer 2026-05-25 11:01:03 +05:00
Emby Toolbox
9bf4588c54 Improve video info JSON viewer 2026-05-25 11:00:03 +05:00
Emby Toolbox
23f9ab3399 Match add settings dialog default height 2026-05-16 21:00:54 +05:00
Emby Toolbox
f1cf86ce92 Increase add settings dialog height 2026-05-16 20:59:33 +05:00
Emby Toolbox
a628ed0a4d Inline custom video bitrate input 2026-05-16 20:46:06 +05:00
Emby Toolbox
fe7fe3db00 Use two-column add settings grids 2026-05-16 20:39:18 +05:00
Emby Toolbox
7f3c2ca999 Remove subtitle profile toggle from UI 2026-05-16 20:35:52 +05:00
Emby Toolbox
2e6c26178f Add effective profile editor for queued files 2026-05-16 20:28:49 +05:00
Emby Toolbox
6cf86d41f5 Register shortcut for Windows toast notifications 2026-05-16 15:38:05 +05:00
Emby Toolbox
36bbc863f8 Compact settings tab layout 2026-05-16 15:34:09 +05:00
18 changed files with 1117 additions and 690 deletions

View File

@ -8,9 +8,9 @@ public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
_ = ToastShortcutRegistration.TryEnsureStartMenuShortcut(NotificationService.ToastAppUserModelId);
_ = AppUserModelIdRegistration.TryRegister(NotificationService.ToastAppUserModelId);
base.OnStartup(e);
}
}

View File

@ -0,0 +1,155 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
namespace EmbyToolbox.Interop;
internal static class ToastShortcutRegistration
{
private const int StgmReadwrite = 0x00000002;
private static readonly PropertyKey AppUserModelIdKey = new(
new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"),
5);
public static string? LastDiagnostics { get; private set; }
public static bool TryEnsureStartMenuShortcut(string appUserModelId)
{
LastDiagnostics = null;
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 10240))
{
LastDiagnostics = "требуется Windows 10 (10240) или новее.";
return false;
}
try
{
var exePath = Environment.ProcessPath;
if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath))
{
LastDiagnostics = "не удалось определить путь к exe.";
return false;
}
var programs = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
var shortcutPath = Path.Combine(programs, "Programs", "Emby Toolbox.lnk");
Directory.CreateDirectory(Path.GetDirectoryName(shortcutPath)!);
var shellLinkObject = (object)new CShellLink();
var shellLink = (IShellLinkW)shellLinkObject;
shellLink.SetPath(exePath);
shellLink.SetArguments(string.Empty);
shellLink.SetWorkingDirectory(Path.GetDirectoryName(exePath) ?? AppContext.BaseDirectory);
shellLink.SetDescription("Emby Toolbox");
if (File.Exists(Path.Combine(AppContext.BaseDirectory, "Resources", "AppIcon.ico")))
{
shellLink.SetIconLocation(Path.Combine(AppContext.BaseDirectory, "Resources", "AppIcon.ico"), 0);
}
else
{
shellLink.SetIconLocation(exePath, 0);
}
using var appId = PropVariant.FromString(appUserModelId);
var propertyStore = (IPropertyStore)shellLink;
propertyStore.SetValue(AppUserModelIdKey, appId);
propertyStore.Commit();
var persistFile = (IPersistFile)shellLink;
persistFile.Save(shortcutPath, true);
return true;
}
catch (Exception ex)
{
LastDiagnostics = $"{ex.GetType().Name}: {ex.Message}";
return false;
}
}
[ComImport]
[Guid("00021401-0000-0000-C000-000000000046")]
private sealed class CShellLink
{
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214F9-0000-0000-C000-000000000046")]
private interface IShellLinkW
{
void GetPath(IntPtr pszFile, int cchMaxPath, IntPtr pfd, uint fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription(IntPtr pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory(IntPtr pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments(IntPtr pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation(IntPtr pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved);
void Resolve(IntPtr hwnd, uint fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("00000138-0000-0000-C000-000000000046")]
private interface IPropertyStore
{
void GetCount(out uint cProps);
void GetAt(uint iProp, out PropertyKey pkey);
void GetValue(ref PropertyKey key, out PropVariant pv);
void SetValue(in PropertyKey key, in PropVariant pv);
void Commit();
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private readonly struct PropertyKey(Guid formatId, int propertyId)
{
private readonly Guid _formatId = formatId;
private readonly int _propertyId = propertyId;
}
[StructLayout(LayoutKind.Sequential)]
private sealed class PropVariant : IDisposable
{
private ushort _valueType;
private ushort _reserved1;
private ushort _reserved2;
private ushort _reserved3;
private IntPtr _value;
private IntPtr _reserved4;
public static PropVariant FromString(string value)
{
return new PropVariant
{
_valueType = 31, // VT_LPWSTR
_value = Marshal.StringToCoTaskMemUni(value)
};
}
public void Dispose()
{
PropVariantClear(this);
GC.SuppressFinalize(this);
}
~PropVariant()
{
PropVariantClear(this);
}
[DllImport("ole32.dll")]
private static extern int PropVariantClear([In, Out] PropVariant pvar);
}
}

View File

@ -368,7 +368,6 @@
<TabItem Header="Подробная информация">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
@ -412,37 +411,6 @@
</Grid>
<Border Grid.Row="1"
BorderBrush="{DynamicResource Ui.Brush.BorderSubtle}"
BorderThickness="0,0,0,1"
Padding="0,0,0,8"
Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Style="{StaticResource UiButtonSecondary}" Margin="0,0,8,0" MinWidth="126" Command="{Binding ExpandAllCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Развернуть всё" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonSecondary}" Margin="0,0,8,0" MinWidth="126" Command="{Binding CollapseAllCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Свернуть всё" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonSecondary}" Margin="0,0,8,0" MinWidth="132" Command="{Binding CopyJsonCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE8C8;" />
<TextBlock Text="Копировать JSON" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonSecondary}" MinWidth="130" Command="{Binding SaveJsonCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE74E;" />
<TextBlock Text="Сохранить JSON" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Border>
<Border Grid.Row="2"
Padding="8,4,8,8"
Background="{DynamicResource Ui.Brush.Surface}"
behaviors:VideoInfoDropTargetBehavior.IsEnabled="True">
@ -463,43 +431,6 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.Resources>
<Style x:Key="VideoInfoTreeItemStyle" TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="2,1" />
<Setter Property="Margin" Value="0,0,0,1" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<EventSetter Event="PreviewMouseRightButtonDown" Handler="OnJsonTreeItemPreviewMouseRightButtonDown" />
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Копировать значение"
Command="{Binding DataContext.VideoInfo.CopyNodeValueCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
<MenuItem Header="Копировать узел"
Command="{Binding DataContext.VideoInfo.CopyNodeLineCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
<MenuItem Header="Копировать узел с дочерними элементами"
Command="{Binding DataContext.VideoInfo.CopyNodeWithChildrenCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
<Separator />
<MenuItem Header="Копировать путь к узлу"
Command="{Binding DataContext.VideoInfo.CopyNodePathCommand, Source={x:Reference RootWindow}}"
CommandParameter="{Binding}" />
</ContextMenu>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
<Setter Property="Foreground" Value="{DynamicResource Ui.Brush.Text}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</Trigger>
</Style.Triggers>
</Style>
</Grid.Resources>
<Grid Grid.RowSpan="2">
<Grid.Style>
@ -541,9 +472,21 @@
</TextBlock.Style>
</TextBlock>
<TreeView Grid.Row="1" ItemsSource="{Binding JsonNodes}" Margin="0" BorderThickness="0" Background="Transparent">
<TreeView.Style>
<Style TargetType="TreeView">
<ListBox Grid.Row="1"
ItemsSource="{Binding FormattedJsonLines}"
Margin="0"
BorderThickness="0"
Background="Transparent"
Foreground="{DynamicResource Ui.Brush.Text}"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
SelectionMode="Extended">
<ListBox.Style>
<Style TargetType="ListBox">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedFilePath}" Value="">
@ -551,36 +494,36 @@
</DataTrigger>
</Style.Triggers>
</Style>
</TreeView.Style>
<TreeView.Resources>
<HierarchicalDataTemplate DataType="{x:Type viewModels:JsonTreeNodeViewModel}" ItemsSource="{Binding Children}">
<Border Background="{Binding RelativeSource={RelativeSource AncestorType=TreeViewItem}, Path=Background}" Padding="2,1">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}" />
<TextBlock Text=": " />
<TextBlock Text="{Binding Value}" Foreground="{DynamicResource Ui.Brush.Muted}" />
</StackPanel>
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowHover}" />
</Trigger>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=TreeViewItem}, Path=IsSelected}" Value="True">
<Setter Property="Background" Value="{DynamicResource Ui.Brush.DataRowSelected}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource Ui.Brush.Text}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</HierarchicalDataTemplate>
</TreeView.Resources>
<TreeView.ItemContainerStyle>
<StaticResource ResourceKey="VideoInfoTreeItemStyle" />
</TreeView.ItemContainerStyle>
</TreeView>
</ListBox.Style>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Focusable" Value="False" />
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBox Text="{Binding Mode=OneWay}"
BorderThickness="0"
Background="Transparent"
Foreground="{DynamicResource Ui.Brush.Text}"
FontFamily="Consolas"
FontSize="13"
IsReadOnly="True"
IsUndoEnabled="False"
IsInactiveSelectionHighlightEnabled="True"
AcceptsReturn="False"
AcceptsTab="False"
TextWrapping="NoWrap"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Disabled" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>
@ -596,40 +539,97 @@
<TextBlock Text="Настройки" VerticalAlignment="Center" />
</StackPanel>
</TabItem.Header>
<TabItem.Resources>
<Style x:Key="SettingsGroupBox" TargetType="GroupBox">
<Setter Property="Padding" Value="6,4,6,6" />
<Setter Property="Margin" Value="0" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="SettingsLabel" TargetType="TextBlock" BasedOn="{StaticResource UiTextCaption}">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,6,0" />
</Style>
<Style x:Key="SettingsTextBox" TargetType="TextBox" BasedOn="{StaticResource UiTextInput}">
<Setter Property="Height" Value="26" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Padding" Value="6,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style x:Key="SettingsComboBox" TargetType="ComboBox" BasedOn="{StaticResource UiCombo}">
<Setter Property="Height" Value="26" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Padding" Value="6,1" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style x:Key="SettingsButton" TargetType="Button" BasedOn="{StaticResource UiButtonSecondary}">
<Setter Property="Height" Value="28" />
<Setter Property="MinHeight" Value="28" />
<Setter Property="Padding" Value="8,1" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style x:Key="SettingsPrimaryButton" TargetType="Button" BasedOn="{StaticResource UiButtonPrimary}">
<Setter Property="Height" Value="28" />
<Setter Property="MinHeight" Value="28" />
<Setter Property="Padding" Value="8,1" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style x:Key="SettingsDangerButton" TargetType="Button" BasedOn="{StaticResource UiButtonDanger}">
<Setter Property="Height" Value="28" />
<Setter Property="MinHeight" Value="28" />
<Setter Property="Padding" Value="8,1" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style x:Key="SettingsDataGridHeader" TargetType="DataGridColumnHeader" BasedOn="{StaticResource UiDataGridColumnHeader}">
<Setter Property="Height" Value="26" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Padding" Value="4,2" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="SettingsDataGridCell" TargetType="DataGridCell" BasedOn="{StaticResource UiDataGridCell}">
<Setter Property="Padding" Value="4,0" />
<Setter Property="FontSize" Value="11" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style x:Key="SettingsDataGridRow" TargetType="DataGridRow" BasedOn="{StaticResource UiDataGridRow}">
<Setter Property="Height" Value="26" />
<Setter Property="MinHeight" Value="26" />
</Style>
</TabItem.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="6" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="0">
<Border Style="{StaticResource UiSectionCard}"
Margin="0,0,0,12"
HorizontalAlignment="Stretch">
<StackPanel HorizontalAlignment="Stretch">
<TextBlock Style="{StaticResource UiTextH2}" Text="Каталоги" />
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2.2*" />
<ColumnDefinition Width="6" />
<ColumnDefinition Width="1.35*" />
<ColumnDefinition Width="6" />
<ColumnDefinition Width="1.15*" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource UiTextCaption}"
Margin="0,12,0,4"
Text="TEMP-каталог" />
<Grid HorizontalAlignment="Stretch">
<GroupBox Grid.Column="0" Header="Пути" Style="{StaticResource SettingsGroupBox}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="76" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="4" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<TextBlock Grid.Column="0" Text="TEMP" Style="{StaticResource SettingsLabel}" />
<Grid Grid.Column="1">
<TextBox x:Name="SettingsTempPathBox"
Style="{StaticResource UiTextInput}"
Style="{StaticResource SettingsTextBox}"
HorizontalAlignment="Stretch"
Text="{Binding ProcessingTempDirectory, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Margin="8,0,0,0"
<TextBlock Margin="6,0,0,0"
VerticalAlignment="Center"
IsHitTestVisible="False"
Text="C:\Users\me\AppData\Local\Temp\EmbyToolbox">
@ -645,107 +645,139 @@
</TextBlock.Style>
</TextBlock>
</Grid>
<Button Grid.Column="2"
MinWidth="100"
Style="{StaticResource UiButtonSecondary}"
<Button Grid.Column="3"
MinWidth="74"
Style="{StaticResource SettingsButton}"
Command="{Binding ChooseTempDirectoryCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE838;" />
<TextBlock Text="Выбрать" VerticalAlignment="Center" />
<TextBlock Text="Обзор" VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</GroupBox>
<TextBlock Style="{StaticResource UiTextCaption}"
Margin="0,6,0,0"
Text="Временный каталог для промежуточных файлов" />
<GroupBox Grid.Column="2" Header="Выполнение" Style="{StaticResource SettingsGroupBox}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="74" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="26" />
<RowDefinition Height="4" />
<RowDefinition Height="26" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Лог" Style="{StaticResource SettingsLabel}" />
<ComboBox Grid.Row="0"
Grid.Column="1"
Style="{StaticResource SettingsComboBox}"
ItemsSource="{Binding LogLevelOptions}"
SelectedItem="{Binding MinimumFileLogLevel, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="GPU" Style="{StaticResource SettingsLabel}" />
<ComboBox Grid.Row="2"
Grid.Column="1"
Style="{StaticResource SettingsComboBox}"
ItemsSource="{Binding HardwareAccelerationOptions}"
SelectedItem="{Binding HardwareAcceleration, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</GroupBox>
<TextBlock Style="{StaticResource UiTextCaption}"
Margin="0,12,0,4"
Text="Минимальный уровень сохранения логов" />
<ComboBox Style="{StaticResource UiCombo}"
MinWidth="200"
Margin="0,0,24,0"
HorizontalAlignment="Stretch"
ItemsSource="{Binding LogLevelOptions}"
SelectedItem="{Binding MinimumFileLogLevel, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<GroupBox Grid.Column="4" Header="Уведомления" Style="{StaticResource SettingsGroupBox}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="24" />
<RowDefinition Height="24" />
<RowDefinition Height="4" />
<RowDefinition Height="28" />
</Grid.RowDefinitions>
<CheckBox Grid.Row="0"
Margin="0"
VerticalAlignment="Center"
IsChecked="{Binding NotifyCompletionSoundAfterQueue, Mode=TwoWay}">
<TextBlock Text="Звук после очереди" VerticalAlignment="Center" />
</CheckBox>
<CheckBox Grid.Row="1"
Margin="0"
VerticalAlignment="Center"
IsChecked="{Binding NotifyWindowsToastAfterQueue, Mode=TwoWay}">
<TextBlock Text="Windows toast" VerticalAlignment="Center" />
</CheckBox>
<Button Grid.Row="3"
MinWidth="118"
HorizontalAlignment="Left"
Style="{StaticResource SettingsButton}"
Command="{Binding TestWindowsNotificationCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xEA8F;" />
<TextBlock Text="Тест" VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</GroupBox>
</Grid>
<TextBlock Style="{StaticResource UiTextCaption}"
Margin="0,12,0,4"
Text="Аппаратное ускорение" />
<ComboBox Style="{StaticResource UiCombo}"
MinWidth="200"
Margin="0,0,24,0"
HorizontalAlignment="Stretch"
ItemsSource="{Binding HardwareAccelerationOptions}"
SelectedItem="{Binding HardwareAcceleration, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource UiTextH2}" Margin="0,16,0,8" Text="Уведомления о конвертации" />
<CheckBox Margin="0,8,0,0"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Top"
FocusVisualStyle="{x:Null}"
IsChecked="{Binding NotifyCompletionSoundAfterQueue, Mode=TwoWay}">
<CheckBox.ToolTip>
<ToolTip MaxWidth="360">
<TextBlock TextWrapping="Wrap"
Text="После обработки всей очереди конвертации воспроизводится системный звук успеха или ошибки (Windows)." />
</ToolTip>
</CheckBox.ToolTip>
<TextBlock Text="Звуковое уведомление после завершения"
TextWrapping="Wrap"
VerticalAlignment="Center" />
</CheckBox>
<CheckBox Margin="0,6,0,0"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Top"
FocusVisualStyle="{x:Null}"
IsChecked="{Binding NotifyWindowsToastAfterQueue, Mode=TwoWay}">
<CheckBox.ToolTip>
<ToolTip MaxWidth="360">
<TextBlock TextWrapping="Wrap"
Text="В центре уведомлений показывается итог обработки всей очереди (или сообщение при остановке пользователем)." />
</ToolTip>
</CheckBox.ToolTip>
<TextBlock Text="Показывать уведомления Windows"
TextWrapping="Wrap"
VerticalAlignment="Center" />
</CheckBox>
<Button Margin="0,12,0,0"
MinHeight="34"
MinWidth="240"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Style="{StaticResource UiButtonSecondary}"
Command="{Binding TestWindowsNotificationCommand}">
<Grid Grid.Row="2" Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Профили конвертации"
Style="{StaticResource UiTextH2}"
VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal">
<Button Style="{StaticResource SettingsButton}" Margin="0,0,6,0" MinWidth="104" Command="{Binding AddConversionProfileCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xEA8F;" />
<TextBlock Margin="8,0,0,0"
VerticalAlignment="Center"
TextWrapping="Wrap"
Text="Проверить уведомление" />
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE710;" />
<TextBlock Text="Добавить" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource SettingsDangerButton}" MinWidth="96" Command="{Binding RemoveConversionProfileCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE74D;" />
<TextBlock Text="Удалить" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
<TextBlock Style="{StaticResource UiTextH2}" Margin="0,16,0,8" Text="Профили конвертации" />
<DataGrid ItemsSource="{Binding ConversionProfiles}"
<DataGrid Grid.Row="3"
ItemsSource="{Binding ConversionProfiles}"
SelectedItem="{Binding SelectedConversionProfile, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
IsReadOnly="False"
Style="{StaticResource UiDataGrid}"
RowStyle="{StaticResource UiDataGridRow}"
ColumnHeaderStyle="{StaticResource UiDataGridColumnHeader}"
CellStyle="{StaticResource UiDataGridCell}"
RowStyle="{StaticResource SettingsDataGridRow}"
ColumnHeaderStyle="{StaticResource SettingsDataGridHeader}"
CellStyle="{StaticResource SettingsDataGridCell}"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
GridLinesVisibility="All"
RowHeight="26"
ColumnHeaderHeight="26"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
MinHeight="160">
ScrollViewer.VerticalScrollBarVisibility="Auto"
MinHeight="0">
<DataGrid.Resources>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontSize" Value="11" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style TargetType="TextBox" BasedOn="{StaticResource SettingsTextBox}">
<Setter Property="Height" Value="24" />
<Setter Property="MinHeight" Value="24" />
<Setter Property="Padding" Value="4,0" />
</Style>
<Style TargetType="ComboBox" BasedOn="{StaticResource SettingsComboBox}">
<Setter Property="Height" Value="24" />
<Setter Property="MinHeight" Value="24" />
<Setter Property="Padding" Value="4,0" />
</Style>
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTemplateColumn Header="Профиль" Width="150">
<DataGridTemplateColumn.CellTemplate>
@ -884,19 +916,6 @@
</DataTemplate>
</DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Субтитры" Width="95">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Subtitles}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
<DataGridTemplateColumn.CellEditingTemplate>
<DataTemplate>
<ComboBox ItemsSource="{Binding DataContext.ConversionYesNoOptions, ElementName=RootWindow}"
SelectedItem="{Binding Subtitles, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Внешние дорожки" Width="125">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
@ -937,50 +956,44 @@
</DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0" HorizontalAlignment="Left">
<Button Style="{StaticResource UiButtonSecondary}" Margin="0,0,8,0" MinWidth="150" Command="{Binding AddConversionProfileCommand}">
<TextBlock Text="Добавить профиль" />
</Button>
<Button Style="{StaticResource UiButtonDanger}" MinWidth="150" Command="{Binding RemoveConversionProfileCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE74D;" />
<TextBlock Text="Удалить профиль" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
</ScrollViewer>
</DataGrid>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource UiButtonSecondary}"
MinWidth="168"
Margin="0,0,8,0"
<Grid Grid.Row="4" Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Style="{StaticResource SettingsButton}"
MinWidth="154"
HorizontalAlignment="Left"
Command="{Binding CheckToolsCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE895;" />
<TextBlock Text="Проверить инструменты" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonSecondary}"
MinWidth="112"
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource SettingsButton}"
MinWidth="96"
Margin="0,0,8,0"
Command="{Binding CancelSettingsCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE711;" />
<TextBlock Text="Отменить" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource UiButtonPrimary}"
MinWidth="112"
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphButton}" Text="&#xE711;" />
<TextBlock Text="Отменить" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource SettingsPrimaryButton}"
MinWidth="98"
Command="{Binding SaveSettingsCommand}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphOnPrimary}" Text="&#xE74E;" />
<TextBlock Text="Сохранить" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Style="{StaticResource UiMdlGlyphOnPrimary}" Text="&#xE74E;" />
<TextBlock Text="Сохранить" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Grid>
</TabItem>

View File

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

View File

@ -5,4 +5,5 @@ public sealed class AddFilesOptions
public string Profile { get; init; } = "Emby";
public bool DisableSubtitleDefault { get; init; }
public bool RemoveForeignAudioAndSubtitles { get; init; }
public EffectiveProfileSettings EffectiveSettings { get; init; } = new();
}

View File

@ -24,6 +24,7 @@ public sealed class ConversionQueueItem : INotifyPropertyChanged
private MediaAnalysisResult? _mediaAnalysis;
private IReadOnlyList<SidecarFile> _sidecars = System.Array.Empty<SidecarFile>();
private IReadOnlyList<ExternalAudioFile> _externalAudioFiles = System.Array.Empty<ExternalAudioFile>();
private EffectiveProfileSettings? _effectiveProfileSettings;
private ConversionPlan? _lastPlan;
private bool _isProcessed;
private bool _processedInCurrentRun;
@ -133,6 +134,21 @@ public sealed class ConversionQueueItem : INotifyPropertyChanged
public ConversionTaskOverride TaskOverride { get; } = new();
public EffectiveProfileSettings? EffectiveProfileSettings
{
get => _effectiveProfileSettings;
set
{
if (ReferenceEquals(_effectiveProfileSettings, value))
{
return;
}
_effectiveProfileSettings = value;
OnPropertyChanged();
}
}
public ConversionPlan? LastPlan
{
get => _lastPlan;
@ -484,6 +500,7 @@ public sealed class ConversionQueueItem : INotifyPropertyChanged
}
_profile = value;
EffectiveProfileSettings = null;
OnPropertyChanged();
}
}

View File

@ -0,0 +1,12 @@
using EmbyToolbox.Services;
namespace EmbyToolbox.Models;
public sealed class EffectiveProfileSettings
{
public string SourceProfileName { get; init; } = "Emby";
public ConversionProfileSettingsEntry Profile { get; init; } = new();
public IReadOnlyList<string> ChangedFields { get; init; } = [];
public bool HasOverrides => ChangedFields.Count > 0;
}

View File

@ -103,7 +103,9 @@ public sealed class ConversionExecutionService
item.ErrorDetails = null;
}).ConfigureAwait(false);
var profile = resolveProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var profile = item.EffectiveProfileSettings?.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)}");

View File

@ -35,6 +35,13 @@ public sealed class NotificationService
private void LogAppUserModelRegistrationState()
{
if (!string.IsNullOrWhiteSpace(ToastShortcutRegistration.LastDiagnostics))
{
_logging.Warning(
$"Windows toast: ярлык Start Menu не подготовлен ({ToastShortcutRegistration.LastDiagnostics})",
"notify");
}
if (string.Equals(AppUserModelIdRegistration.LastRegisteredId, ToastAppUserModelId, StringComparison.Ordinal))
{
_logging.Info($"App User Model ID: {ToastAppUserModelId}", "notify");

View File

@ -0,0 +1,65 @@
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class ProfileOverrideBuilder
{
public EffectiveProfileSettings Build(
ConversionProfileSettingsEntry source,
ConversionProfileSettingsEntry formValues,
string sourceProfileName)
{
var changed = new List<string>();
AddIfChanged(changed, "Container", source.Container, formValues.Container);
AddIfChanged(changed, "Video", source.Video, formValues.Video);
AddIfChanged(changed, "PixelFormat", source.PixelFormat, formValues.PixelFormat);
AddIfChanged(changed, "Resolution", source.Resolution, formValues.Resolution);
AddIfChanged(changed, "Fps", source.Fps, formValues.Fps);
AddIfChanged(changed, "Audio", source.Audio, formValues.Audio);
AddIfChanged(changed, "Bitrate", source.Bitrate, formValues.Bitrate);
AddIfChanged(changed, "VideoBitrateMode", source.VideoBitrateMode, formValues.VideoBitrateMode);
if (source.VideoBitrateMbps != formValues.VideoBitrateMbps)
{
changed.Add("VideoBitrateMbps");
}
AddIfChanged(changed, "Subtitles", source.Subtitles, formValues.Subtitles);
AddIfChanged(changed, "ExternalTracks", source.ExternalTracks, formValues.ExternalTracks);
AddIfChanged(changed, "ExternalSubtitles", source.ExternalSubtitles, formValues.ExternalSubtitles);
AddIfChanged(changed, "Fonts", source.Fonts, formValues.Fonts);
return new EffectiveProfileSettings
{
SourceProfileName = sourceProfileName,
Profile = Clone(formValues, sourceProfileName),
ChangedFields = changed
};
}
public static ConversionProfileSettingsEntry Clone(ConversionProfileSettingsEntry source, string? profileName = null) =>
new()
{
Profile = string.IsNullOrWhiteSpace(profileName) ? source.Profile : profileName!,
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 void AddIfChanged(List<string> changed, string name, string? source, string? value)
{
if (!string.Equals(source?.Trim(), value?.Trim(), StringComparison.Ordinal))
{
changed.Add(name);
}
}
}

View File

@ -102,7 +102,7 @@ public sealed class QueueAnalysisService
await uiInvoke(
() =>
{
var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var profile = ResolveEffectiveProfile(item);
item.TaskOverride.TrackOverrides.Clear();
TrackOverrideSeeder.EnsureDefaults(
item.TaskOverride,
@ -297,7 +297,7 @@ public sealed class QueueAnalysisService
var audio = FfprobeAudioInfoParser.TryParse(result.Json) ?? new FfprobeAudioInfo(0, null, true);
var side = discovery.Sidecars;
var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var profile = ResolveEffectiveProfile(item);
// При повторном анализе sidecar-набор может измениться (добавили/удалили внешние файлы).
// Пересобираем список дорожек, чтобы не держать устаревшие external entries.
item.TaskOverride.TrackOverrides.Clear();
@ -419,6 +419,11 @@ public sealed class QueueAnalysisService
public int ErrorCount;
}
private ConversionProfileSettingsEntry ResolveEffectiveProfile(ConversionQueueItem item) =>
item.EffectiveProfileSettings?.Profile
?? _profile.GetProfile(item.Profile)
?? ConversionProfileMapping.EmbyFallback;
private static bool IsForeignLanguageForAutoRemove(string? language)
{
if (string.IsNullOrWhiteSpace(language))

View File

@ -15,6 +15,7 @@ public static class VideoBitratePolicy
Auto,
Source,
"2 Mbps",
"3 Mbps",
"4 Mbps",
"6 Mbps",
"8 Mbps",

View File

@ -1,102 +0,0 @@
using System.ComponentModel;
using System.Collections.ObjectModel;
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 _disableSubtitleDefault;
private bool _removeForeignAudioAndSubtitles;
private ConversionProfilePresetRow? _selectedProfile;
public AddFilesOptionsViewModel(
IReadOnlyList<ConversionProfilePresetRow> profiles,
string selectedProfileName,
bool disableSubtitleDefault,
bool removeForeignAudioAndSubtitles,
Action<AddFilesOptions> onAdd,
Action onCancel)
{
_onAdd = onAdd;
_onCancel = onCancel;
_disableSubtitleDefault = disableSubtitleDefault;
_removeForeignAudioAndSubtitles = removeForeignAudioAndSubtitles;
Profiles = new ObservableCollection<ConversionProfilePresetRow>(profiles);
_selectedProfile = Profiles.FirstOrDefault(p => p.Profile.Equals(selectedProfileName, StringComparison.OrdinalIgnoreCase))
?? Profiles.FirstOrDefault(p => p.Profile.Equals("Emby", StringComparison.OrdinalIgnoreCase))
?? Profiles.FirstOrDefault();
AddCommand = new RelayCommand(ExecuteAdd);
CancelCommand = new RelayCommand(() => _onCancel());
}
public bool DisableSubtitleDefault
{
get => _disableSubtitleDefault;
set
{
if (_disableSubtitleDefault == value)
{
return;
}
_disableSubtitleDefault = value;
OnPropertyChanged();
}
}
public bool RemoveForeignAudioAndSubtitles
{
get => _removeForeignAudioAndSubtitles;
set
{
if (_removeForeignAudioAndSubtitles == value)
{
return;
}
_removeForeignAudioAndSubtitles = value;
OnPropertyChanged();
}
}
public ObservableCollection<ConversionProfilePresetRow> Profiles { get; }
public ConversionProfilePresetRow? SelectedProfile
{
get => _selectedProfile;
set
{
if (ReferenceEquals(_selectedProfile, value))
{
return;
}
_selectedProfile = value;
OnPropertyChanged();
}
}
public RelayCommand AddCommand { get; }
public RelayCommand CancelCommand { get; }
private void ExecuteAdd()
{
_onAdd(
new AddFilesOptions
{
Profile = string.IsNullOrWhiteSpace(_selectedProfile?.Profile) ? "Emby" : _selectedProfile.Profile,
DisableSubtitleDefault = _disableSubtitleDefault,
RemoveForeignAudioAndSubtitles = _removeForeignAudioAndSubtitles
});
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View File

@ -0,0 +1,351 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using EmbyToolbox.Models;
using EmbyToolbox.Services;
namespace EmbyToolbox.ViewModels;
public sealed class AddFilesSettingsViewModel : INotifyPropertyChanged
{
private readonly IReadOnlyList<ConversionProfilePresetRow> _profiles;
private readonly Action<AddFilesOptions> _onAdd;
private readonly Action _onCancel;
private readonly ProfileOverrideBuilder _builder = new();
private ConversionProfileSettingsEntry _sourceProfile = new();
private ConversionProfilePresetRow? _selectedProfile;
private string _container = string.Empty;
private string _video = string.Empty;
private string _pixelFormat = string.Empty;
private string _resolution = string.Empty;
private string _fps = string.Empty;
private string _videoBitrateMode = VideoBitratePolicy.Auto;
private string _videoBitrateCustomMbps = string.Empty;
private string _audio = string.Empty;
private string _audioBitrate = string.Empty;
private bool _externalTracks;
private bool _externalSubtitles;
private bool _fonts;
private bool _removeForeignTracks;
private bool _disableSubtitleDefault;
private string _validationMessage = string.Empty;
private bool _isValid;
public AddFilesSettingsViewModel(
IReadOnlyList<ConversionProfilePresetRow> profiles,
ConversionFormOptions formOptions,
AddFilesSettingsState? previousState,
string fallbackProfileName,
bool fallbackDisableSubtitleDefault,
bool fallbackRemoveForeignTracks,
Action<AddFilesOptions> onAdd,
Action onCancel)
{
_profiles = profiles;
_onAdd = onAdd;
_onCancel = onCancel;
Profiles = new ObservableCollection<ConversionProfilePresetRow>(profiles);
ContainerOptions = formOptions.ContainerOptions;
VideoCodecOptions = formOptions.VideoCodecOptions;
PixelFormatOptions = formOptions.PixelFormatOptions;
ResolutionOptions = formOptions.ResolutionOptions;
FpsOptions = formOptions.FpsOptions;
AudioCodecOptions = formOptions.AudioCodecOptions;
AudioBitrateOptions = formOptions.AudioBitrateKbps;
VideoBitrateOptions = VideoBitratePolicy.UiOptions;
YesNoOptions = ["Да", "Нет"];
AddCommand = new RelayCommand(ExecuteAdd, CanAdd);
CancelCommand = new RelayCommand(() => _onCancel());
var profileName = string.IsNullOrWhiteSpace(previousState?.ProfileName)
? fallbackProfileName
: previousState!.ProfileName;
_selectedProfile = Profiles.FirstOrDefault(p => p.Profile.Equals(profileName, StringComparison.OrdinalIgnoreCase))
?? Profiles.FirstOrDefault(p => p.Profile.Equals("Emby", StringComparison.OrdinalIgnoreCase))
?? Profiles.FirstOrDefault();
if (_selectedProfile is not null)
{
LoadProfile(_selectedProfile.ToSettingsEntry(), resetUserValues: true);
}
if (previousState is not null)
{
ApplyState(previousState);
}
else
{
_disableSubtitleDefault = fallbackDisableSubtitleDefault;
_removeForeignTracks = fallbackRemoveForeignTracks;
}
Validate();
}
public ObservableCollection<ConversionProfilePresetRow> Profiles { get; }
public IReadOnlyList<string> ContainerOptions { get; }
public IReadOnlyList<string> VideoCodecOptions { get; }
public IReadOnlyList<string> PixelFormatOptions { get; }
public IReadOnlyList<string> ResolutionOptions { get; }
public IReadOnlyList<string> FpsOptions { get; }
public IReadOnlyList<string> AudioCodecOptions { get; }
public IReadOnlyList<string> AudioBitrateOptions { get; }
public IReadOnlyList<string> VideoBitrateOptions { get; }
public IReadOnlyList<string> YesNoOptions { get; }
public RelayCommand AddCommand { get; }
public RelayCommand CancelCommand { get; }
public ConversionProfilePresetRow? SelectedProfile
{
get => _selectedProfile;
set
{
if (ReferenceEquals(_selectedProfile, value))
{
return;
}
_selectedProfile = value;
OnPropertyChanged();
if (value is not null)
{
LoadProfile(value.ToSettingsEntry(), resetUserValues: true);
}
}
}
public string Container { get => _container; set => SetField(ref _container, value); }
public string Video { get => _video; set => SetField(ref _video, value); }
public string PixelFormat { get => _pixelFormat; set => SetField(ref _pixelFormat, value); }
public string Resolution { get => _resolution; set => SetField(ref _resolution, value); }
public string Fps { get => _fps; set => SetField(ref _fps, value); }
public string VideoBitrateMode
{
get => _videoBitrateMode;
set
{
if (SetField(ref _videoBitrateMode, string.IsNullOrWhiteSpace(value) ? VideoBitratePolicy.Auto : value))
{
OnPropertyChanged(nameof(IsVideoBitrateCustomVisible));
}
}
}
public string VideoBitrateCustomMbps { get => _videoBitrateCustomMbps; set => SetField(ref _videoBitrateCustomMbps, value); }
public string Audio { get => _audio; set => SetField(ref _audio, value); }
public string AudioBitrate { get => _audioBitrate; set => SetField(ref _audioBitrate, value); }
public bool ExternalTracks { get => _externalTracks; set => SetField(ref _externalTracks, value); }
public bool ExternalSubtitles { get => _externalSubtitles; set => SetField(ref _externalSubtitles, value); }
public bool Fonts { get => _fonts; set => SetField(ref _fonts, value); }
public bool RemoveForeignTracks { get => _removeForeignTracks; set => SetField(ref _removeForeignTracks, value); }
public bool DisableSubtitleDefault { get => _disableSubtitleDefault; set => SetField(ref _disableSubtitleDefault, value); }
public bool IsVideoBitrateCustomVisible => string.Equals(VideoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal);
public bool HasValidationMessage => !string.IsNullOrWhiteSpace(ValidationMessage);
public string ValidationMessage
{
get => _validationMessage;
private set
{
if (_validationMessage == value)
{
return;
}
_validationMessage = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasValidationMessage));
}
}
public AddFilesSettingsState CaptureState() =>
new AddFilesSettingsState
{
ProfileName = SelectedProfile?.Profile ?? "Emby",
ChangedFields = BuildEffectiveSettings().ChangedFields.ToArray(),
Container = Container,
Video = Video,
PixelFormat = PixelFormat,
Resolution = Resolution,
Fps = Fps,
VideoBitrateMode = VideoBitrateMode,
VideoBitrateCustomMbps = VideoBitrateCustomMbps,
Audio = Audio,
AudioBitrate = AudioBitrate,
ExternalTracks = ExternalTracks,
ExternalSubtitles = ExternalSubtitles,
Fonts = Fonts,
RemoveForeignTracks = RemoveForeignTracks,
DisableSubtitleDefault = DisableSubtitleDefault
};
private void LoadProfile(ConversionProfileSettingsEntry profile, bool resetUserValues)
{
_sourceProfile = ProfileOverrideBuilder.Clone(profile);
if (!resetUserValues)
{
return;
}
Container = profile.Container;
Video = profile.Video;
PixelFormat = profile.PixelFormat;
Resolution = profile.Resolution;
Fps = profile.Fps;
VideoBitrateMode = VideoBitratePolicy.NormalizeMode(profile.VideoBitrateMode);
VideoBitrateCustomMbps = profile.VideoBitrateMbps?.ToString("0.###", CultureInfo.InvariantCulture) ?? string.Empty;
Audio = profile.Audio;
AudioBitrate = profile.Bitrate;
ExternalTracks = IsYes(profile.ExternalTracks);
ExternalSubtitles = IsYes(profile.ExternalSubtitles);
Fonts = IsYes(profile.Fonts);
}
private void ApplyState(AddFilesSettingsState state)
{
var changed = state.ChangedFields.ToHashSet(StringComparer.Ordinal);
if (changed.Contains("Container")) Container = state.Container;
if (changed.Contains("Video")) Video = state.Video;
if (changed.Contains("PixelFormat")) PixelFormat = state.PixelFormat;
if (changed.Contains("Resolution")) Resolution = state.Resolution;
if (changed.Contains("Fps")) Fps = state.Fps;
if (changed.Contains("VideoBitrateMode")) VideoBitrateMode = state.VideoBitrateMode;
if (changed.Contains("VideoBitrateMbps")) VideoBitrateCustomMbps = state.VideoBitrateCustomMbps;
if (changed.Contains("Audio")) Audio = state.Audio;
if (changed.Contains("Bitrate")) AudioBitrate = state.AudioBitrate;
if (changed.Contains("ExternalTracks")) ExternalTracks = state.ExternalTracks;
if (changed.Contains("ExternalSubtitles")) ExternalSubtitles = state.ExternalSubtitles;
if (changed.Contains("Fonts")) Fonts = state.Fonts;
RemoveForeignTracks = state.RemoveForeignTracks;
DisableSubtitleDefault = state.DisableSubtitleDefault;
}
private bool CanAdd() => _isValid;
private void ExecuteAdd()
{
if (!Validate())
{
return;
}
var effective = BuildEffectiveSettings();
_onAdd(
new AddFilesOptions
{
Profile = effective.SourceProfileName,
DisableSubtitleDefault = DisableSubtitleDefault,
RemoveForeignAudioAndSubtitles = RemoveForeignTracks,
EffectiveSettings = effective
});
}
private EffectiveProfileSettings BuildEffectiveSettings()
{
var form = new ConversionProfileSettingsEntry
{
Profile = SelectedProfile?.Profile ?? "Emby",
Container = Container,
Video = Video,
PixelFormat = PixelFormat,
Resolution = Resolution,
Fps = Fps,
Audio = Audio,
Bitrate = AudioBitrate,
VideoBitrateMode = VideoBitrateMode,
VideoBitrateMbps = IsVideoBitrateCustomVisible && TryParseCustomVideoBitrate(VideoBitrateCustomMbps, out var mbps) ? mbps : null,
Subtitles = _sourceProfile.Subtitles,
ExternalTracks = ToYesNo(ExternalTracks),
ExternalSubtitles = ToYesNo(ExternalSubtitles),
Fonts = ToYesNo(Fonts)
};
return _builder.Build(_sourceProfile, form, SelectedProfile?.Profile ?? "Emby");
}
private bool Validate()
{
if (SelectedProfile is null)
{
ValidationMessage = "Выберите профиль.";
_isValid = false;
AddCommand.RaiseCanExecuteChanged();
return false;
}
if (IsVideoBitrateCustomVisible && !TryParseCustomVideoBitrate(VideoBitrateCustomMbps, out _))
{
ValidationMessage = "Видеобитрейт должен быть числом больше 0.";
_isValid = false;
AddCommand.RaiseCanExecuteChanged();
return false;
}
ValidationMessage = string.Empty;
_isValid = true;
AddCommand.RaiseCanExecuteChanged();
return true;
}
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
Validate();
return true;
}
private static bool TryParseCustomVideoBitrate(string? raw, out double mbps)
{
mbps = 0;
if (string.IsNullOrWhiteSpace(raw))
{
return false;
}
return double.TryParse(raw.Trim().Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out mbps)
&& mbps > 0;
}
private static bool IsYes(string? value) =>
value is not null
&& (value.Equals("Да", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Yes", StringComparison.OrdinalIgnoreCase)
|| value.Equals("true", StringComparison.OrdinalIgnoreCase));
private static string ToYesNo(bool value) => value ? "Да" : "Нет";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public sealed class AddFilesSettingsState
{
public string ProfileName { get; init; } = "Emby";
public IReadOnlyList<string> ChangedFields { get; init; } = [];
public string Container { get; init; } = string.Empty;
public string Video { get; init; } = string.Empty;
public string PixelFormat { get; init; } = string.Empty;
public string Resolution { get; init; } = string.Empty;
public string Fps { get; init; } = string.Empty;
public string VideoBitrateMode { get; init; } = VideoBitratePolicy.Auto;
public string VideoBitrateCustomMbps { get; init; } = string.Empty;
public string Audio { get; init; } = string.Empty;
public string AudioBitrate { get; init; } = string.Empty;
public bool ExternalTracks { get; init; }
public bool ExternalSubtitles { get; init; }
public bool Fonts { get; init; }
public bool RemoveForeignTracks { get; init; }
public bool DisableSubtitleDefault { get; init; }
}

View File

@ -11,7 +11,8 @@ public sealed class ConversionFormOptions
public List<string> PixelFormatOptions { get; } = ["yuv420p", "yuv420p10le", "yuv422p", "yuv444p"];
public List<string> ResolutionOptions { get; } = ["Без изменений", "Максимум 2160p", "Максимум 1440p", "Максимум 1080p", "Максимум 720p"];
public List<string> FpsOptions { get; } = ["Без изменений", "Максимум 60", "Максимум 30", "Максимум 25", "Максимум 24"];
public List<string> AudioBitrateKbps { get; } = ["128 kbps", "192 kbps", "256 kbps", "320 kbps"];
public List<string> AudioCodecOptions { get; } = ["AAC", "AC3", "EAC3", "Opus", "MP3", "FLAC", "Copy"];
public List<string> AudioBitrateKbps { get; } = ["96 kbps", "128 kbps", "160 kbps", "192 kbps", "256 kbps", "320 kbps"];
public List<string> VideoBitrateModeOptions { get; } = VideoBitratePolicy.UiOptions.ToList();
internal void RestoreListsFromSerialized(IReadOnlyList<string>? containers,

View File

@ -3,6 +3,7 @@ using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
@ -44,6 +45,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
private bool _copyPreviousTrackSettings;
private bool _disableSubtitleDefault;
private bool _removeForeignTracksByDefault;
private AddFilesSettingsState? _lastAddFilesSettingsState;
private ConversionProfilePresetRow? _selectedDefaultProfile;
private bool _isQueueDropHighlight;
private bool _isExecutionRunning;
@ -611,7 +613,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
return;
}
var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var prof = ResolveEffectiveProfile(item);
TrackOverrideSeeder.SyncTargetFieldsFromProfile(item.TaskOverride, prof);
var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles);
item.SetPlan(plan);
@ -630,7 +632,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
&& (string.Equals(item.Status, ConversionQueueStatus.Pending, StringComparison.Ordinal)
|| string.Equals(item.Status, ConversionQueueStatus.Ready, StringComparison.Ordinal)))
{
var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var prof = ResolveEffectiveProfile(item);
// Keep per-file manual overrides intact; only untouched tasks follow updated profile targets.
if (!item.IsManuallyEdited)
{
@ -890,7 +892,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
var affected = 0;
foreach (var item in analysis.MajorityItems)
{
var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var prof = ResolveEffectiveProfile(item);
var plan = _planService.Build(item.MediaAnalysis!, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles);
item.IsManuallyEdited = true;
item.SetPlan(plan);
@ -1378,7 +1380,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
t.FfprobeAudioSizeMb,
t.FfprobeAudioSizePartial);
var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var prof = ResolveEffectiveProfile(item);
var plan = _planService.Build(item.MediaAnalysis!, sidecars, prof, item.TaskOverride, ext);
item.SetPlan(plan);
item.Status = NormalizeLoadedExecutionStatus(t.Status);
@ -1556,6 +1558,15 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
}
var profile = string.IsNullOrWhiteSpace(addOptions.Profile) ? "Emby" : addOptions.Profile.Trim();
var effectiveSettings = addOptions.EffectiveSettings.Profile.Profile.Length > 0
? addOptions.EffectiveSettings
: new EffectiveProfileSettings
{
SourceProfileName = profile,
Profile = _profile.GetProfile(profile) ?? ConversionProfileMapping.EmbyFallback,
ChangedFields = []
};
LogAddFilesEffectiveSettings(effectiveSettings, addOptions);
var added = 0;
var dups = 0;
var newBatch = new List<ConversionQueueItem>();
@ -1600,8 +1611,10 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
Status = ConversionQueueStatus.Analyzing,
Progress = 0,
Profile = profile,
EffectiveProfileSettings = effectiveSettings,
PlanSummary = "Анализ…"
};
TrackOverrideSeeder.SyncTargetFieldsFromProfile(item.TaskOverride, effectiveSettings.Profile);
QueueTasks.Add(item);
newBatch.Add(item);
added++;
@ -1624,6 +1637,27 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
private string CurrentProfileNameForNewTasks() =>
string.IsNullOrWhiteSpace(_defaultQueueProfile) ? "Emby" : _defaultQueueProfile;
private ConversionProfileSettingsEntry ResolveEffectiveProfile(ConversionQueueItem item) =>
item.EffectiveProfileSettings?.Profile
?? _profile.GetProfile(item.Profile)
?? ConversionProfileMapping.EmbyFallback;
private void LogAddFilesEffectiveSettings(EffectiveProfileSettings effectiveSettings, AddFilesOptions addOptions)
{
var p = effectiveSettings.Profile;
var changed = effectiveSettings.ChangedFields.Count == 0
? "none"
: string.Join(", ", effectiveSettings.ChangedFields);
_logging.Info(
"AddFiles effective settings:" + Environment.NewLine
+ $"Profile={effectiveSettings.SourceProfileName}" + Environment.NewLine
+ $"Overrides={changed}" + Environment.NewLine
+ $"Container={p.Container}; Video={p.Video}; PixelFormat={p.PixelFormat}; Resolution={p.Resolution}; FPS={p.Fps}; VideoBitrate={p.VideoBitrateMode}; VideoBitrateMbps={p.VideoBitrateMbps?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-"}" + Environment.NewLine
+ $"Audio={p.Audio}; AudioBitrate={p.Bitrate}; Subtitles={p.Subtitles}; ExternalAudio={p.ExternalTracks}; ExternalSubtitles={p.ExternalSubtitles}; ExternalFonts={p.Fonts}" + Environment.NewLine
+ $"ForeignTracks={(addOptions.RemoveForeignAudioAndSubtitles ? "Remove" : "Keep")}; DisableSubtitleDefault={addOptions.DisableSubtitleDefault}",
"conversion.queue");
}
private void RenumberQueue()
{
for (var i = 0; i < QueueTasks.Count; i++)
@ -1769,8 +1803,10 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
};
AddFilesOptions? selected = null;
var vm = new AddFilesOptionsViewModel(
var vm = new AddFilesSettingsViewModel(
_presetRowsForSetup(),
FormOptions,
_lastAddFilesSettingsState,
CurrentProfileNameForNewTasks(),
DisableSubtitleDefault,
RemoveForeignTracksByDefault,
@ -1780,6 +1816,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
_defaultQueueProfile = options.Profile;
DisableSubtitleDefault = options.DisableSubtitleDefault;
RemoveForeignTracksByDefault = options.RemoveForeignAudioAndSubtitles;
_lastAddFilesSettingsState = ((AddFilesSettingsViewModel)dialog.DataContext).CaptureState();
SyncDefaultProfileFromList(_presetRowsForSetup());
dialog.DialogResult = true;
dialog.Close();
@ -2151,7 +2188,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
}
}
var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback;
var profile = ResolveEffectiveProfile(item);
var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, profile, item.TaskOverride, item.ExternalAudioFiles);
item.SetPlan(plan);
}

View File

@ -1,4 +1,3 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
@ -23,7 +22,8 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
private string _selectedFilePath = string.Empty;
private string _analysisStateText = string.Empty;
private string _errorMessage = string.Empty;
private string _rawJson = string.Empty;
private string _formattedJson = string.Empty;
private IReadOnlyList<string> _formattedJsonLines = Array.Empty<string>();
private string _summaryText = string.Empty;
private bool _isBusy;
private bool _isVideoInfoDropHighlight;
@ -44,41 +44,16 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
SelectFileCommand = new RelayCommand(ExecuteSelectFile);
SelectSummaryFilesCommand = new RelayCommand(ExecuteSelectSummaryFiles);
ExpandAllCommand = new RelayCommand(ExecuteExpandAll);
CollapseAllCommand = new RelayCommand(ExecuteCollapseAll);
CopyJsonCommand = new RelayCommand(ExecuteCopyJson, () => !string.IsNullOrWhiteSpace(_rawJson));
SaveJsonCommand = new RelayCommand(ExecuteSaveJson, () => !string.IsNullOrWhiteSpace(_rawJson));
CopySummaryCommand = new RelayCommand(ExecuteCopySummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован");
SaveSummaryCommand = new RelayCommand(ExecuteSaveSummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован");
CopyNodeValueCommand = new RelayCommand(ExecuteCopyNodeValue, CanCopyNodeValue);
CopyNodeLineCommand = new RelayCommand(ExecuteCopyNodeLine, CanCopyNodeLine);
CopyNodeWithChildrenCommand = new RelayCommand(ExecuteCopyNodeWithChildren, CanCopyNodeWithChildren);
CopyNodePathCommand = new RelayCommand(ExecuteCopyNodePath, CanCopyNodePath);
}
public ObservableCollection<JsonTreeNodeViewModel> JsonNodes { get; } = new();
public ICommand SelectFileCommand { get; }
public ICommand SelectSummaryFilesCommand { get; }
public ICommand ExpandAllCommand { get; }
public ICommand CollapseAllCommand { get; }
public ICommand CopyJsonCommand { get; }
public ICommand SaveJsonCommand { get; }
public ICommand CopySummaryCommand { get; }
public ICommand SaveSummaryCommand { get; }
public ICommand CopyNodeValueCommand { get; }
public ICommand CopyNodeLineCommand { get; }
public ICommand CopyNodeWithChildrenCommand { get; }
public ICommand CopyNodePathCommand { get; }
public string SelectedFilePath
{
get => _selectedFilePath;
@ -124,6 +99,37 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
}
}
public string FormattedJson
{
get => _formattedJson;
private set
{
if (_formattedJson == value)
{
return;
}
_formattedJson = value;
FormattedJsonLines = SplitTextLines(value);
OnPropertyChanged();
}
}
public IReadOnlyList<string> FormattedJsonLines
{
get => _formattedJsonLines;
private set
{
if (ReferenceEquals(_formattedJsonLines, value))
{
return;
}
_formattedJsonLines = value;
OnPropertyChanged();
}
}
public bool IsBusy
{
get => _isBusy;
@ -307,9 +313,8 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
private async Task AnalyzeAsync()
{
JsonNodes.Clear();
ErrorMessage = string.Empty;
_rawJson = string.Empty;
FormattedJson = string.Empty;
RaiseCommandStates();
if (string.IsNullOrWhiteSpace(SelectedFilePath))
@ -335,8 +340,7 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
try
{
_rawJson = result.Json;
BuildTree(_rawJson);
FormattedJson = FormatJsonOrFallback(result.Json);
AnalysisStateText = "Готово";
RaiseCommandStates();
_logging.Info($"ffprobe завершен: {Path.GetFileName(SelectedFilePath)}", "video-info.ffprobe", command: result.Command, stdout: result.StdOut, stderr: result.StdErr);
@ -427,125 +431,6 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
RaiseCommandStates();
}
private void BuildTree(string json)
{
using var doc = JsonDocument.Parse(json);
JsonNodes.Clear();
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in doc.RootElement.EnumerateObject())
{
JsonNodes.Add(CreateNode(property.Name, property.Value, null));
}
}
else
{
JsonNodes.Add(CreateNode("root", doc.RootElement, null));
}
}
private static JsonTreeNodeViewModel CreateNode(string name, JsonElement element, JsonTreeNodeViewModel? parent)
{
var node = new JsonTreeNodeViewModel(name, GetPreviewValue(element), element.GetRawText(), parent);
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var prop in element.EnumerateObject())
{
node.Children.Add(CreateNode(prop.Name, prop.Value, node));
}
break;
case JsonValueKind.Array:
var index = 0;
foreach (var item in element.EnumerateArray())
{
node.Children.Add(CreateNode($"[{index}]", item, node));
index++;
}
break;
}
return node;
}
private static string GetPreviewValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => "{...}",
JsonValueKind.Array => "[...]",
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number => element.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => element.GetRawText()
};
}
private void ExecuteExpandAll()
{
SetExpandedState(JsonNodes, true);
}
private void ExecuteCollapseAll()
{
SetExpandedState(JsonNodes, false);
}
private static void SetExpandedState(IEnumerable<JsonTreeNodeViewModel> nodes, bool isExpanded)
{
foreach (var node in nodes)
{
node.IsExpanded = isExpanded;
SetExpandedState(node.Children, isExpanded);
}
}
private void ExecuteCopyJson()
{
if (string.IsNullOrWhiteSpace(_rawJson))
{
return;
}
Clipboard.SetText(_rawJson);
_logging.Info("JSON скопирован в буфер обмена", "video-info.copy");
}
private void ExecuteSaveJson()
{
if (string.IsNullOrWhiteSpace(_rawJson))
{
return;
}
var defaultName = string.IsNullOrWhiteSpace(SelectedFilePath)
? "ffprobe.json"
: $"{Path.GetFileNameWithoutExtension(SelectedFilePath)}.ffprobe.json";
var dialog = new SaveFileDialog
{
Title = "Сохранить JSON ffprobe",
Filter = "JSON (*.json)|*.json|Все файлы|*.*",
FileName = defaultName,
InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder),
};
if (dialog.ShowDialog() != true)
{
return;
}
File.WriteAllText(dialog.FileName, _rawJson);
_recentPaths.RememberChosenFolder(
RecentPathScenario.SettingsOutputFolder,
Path.GetDirectoryName(dialog.FileName) ?? dialog.FileName);
_logging.Info($"JSON сохранен: {dialog.FileName}", "video-info.save");
}
private void ExecuteCopySummary()
{
if (string.IsNullOrWhiteSpace(SummaryText) || SummaryText == "Файл не проанализирован")
@ -594,105 +479,8 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
private void RaiseCommandStates()
{
(CopyJsonCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveJsonCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopySummaryCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveSummaryCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeValueCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeLineCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeWithChildrenCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodePathCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
private static JsonTreeNodeViewModel? AsNode(object? parameter)
{
return parameter as JsonTreeNodeViewModel;
}
private bool CanCopyNodeValue(object? parameter)
{
var node = AsNode(parameter);
return node is not null && node.Children.Count == 0;
}
private bool CanCopyNodeLine(object? parameter)
{
return AsNode(parameter) is not null;
}
private bool CanCopyNodeWithChildren(object? parameter)
{
return AsNode(parameter) is not null;
}
private bool CanCopyNodePath(object? parameter)
{
return AsNode(parameter) is not null;
}
private void ExecuteCopyNodeValue(object? parameter)
{
var node = AsNode(parameter);
if (node is null || node.Children.Count > 0)
{
return;
}
Clipboard.SetText(node.Value);
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodeLine(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
Clipboard.SetText($"{node.Name}: {node.Value}");
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodeWithChildren(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
var formatted = FormatJsonOrFallback(node.SubtreeJson);
Clipboard.SetText(formatted);
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodePath(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
Clipboard.SetText(BuildNodePath(node));
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private static string BuildNodePath(JsonTreeNodeViewModel node)
{
if (node.Parent is null)
{
return node.Name;
}
var parentPath = BuildNodePath(node.Parent);
if (node.Name.StartsWith("[", StringComparison.Ordinal))
{
return $"{parentPath}{node.Name}";
}
return string.IsNullOrWhiteSpace(parentPath) ? node.Name : $"{parentPath}.{node.Name}";
}
private static string FormatJsonOrFallback(string json)
@ -707,5 +495,15 @@ public sealed class VideoInfoViewModel : INotifyPropertyChanged
return json;
}
}
private static IReadOnlyList<string> SplitTextLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return Array.Empty<string>();
}
return text.ReplaceLineEndings("\n").Split('\n');
}
}

View File

@ -1,96 +1,178 @@
<Window x:Class="EmbyToolbox.Views.AddFilesOptionsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:EmbyToolbox.Converters"
Title="Параметры добавления"
Width="520"
Height="230"
ResizeMode="NoResize"
Width="620"
Height="537"
MinWidth="600"
MinHeight="460"
ResizeMode="CanResize"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Ui.Brush.Surface}">
<Grid Margin="14">
<Window.Resources>
<converters:BooleanToVisibilityConverter x:Key="BoolToVis" />
<Style x:Key="AddSettingsLabel" TargetType="TextBlock" BasedOn="{StaticResource UiTextCaption}">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,6,3" />
</Style>
<Style x:Key="AddSettingsCombo" TargetType="ComboBox" BasedOn="{StaticResource UiCombo}">
<Setter Property="Height" Value="26" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Margin" Value="0,0,8,6" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style x:Key="AddSettingsTextBox" TargetType="TextBox" BasedOn="{StaticResource UiTextInput}">
<Setter Property="Height" Value="26" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Margin" Value="0,0,8,6" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style x:Key="AddSettingsGroup" TargetType="GroupBox">
<Setter Property="Padding" Value="6,4,6,4" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="BorderBrush" Value="{DynamicResource Ui.Brush.BorderSubtle}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontSize" Value="11" />
</Style>
</Window.Resources>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="16" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid Grid.Row="0" Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="220" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Профиль"
Style="{StaticResource UiTextCaption}"
VerticalAlignment="Center"
Margin="0,0,12,0" />
<TextBlock Grid.Column="0" Text="Профиль:" Style="{StaticResource AddSettingsLabel}" Margin="0,0,8,0" />
<ComboBox Grid.Column="1"
Height="32"
Style="{StaticResource UiCombo}"
Style="{StaticResource AddSettingsCombo}"
Margin="0"
ItemsSource="{Binding Profiles}"
SelectedItem="{Binding SelectedProfile, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Profile, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Profile}" TextTrimming="CharacterEllipsis" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<Grid Grid.Row="2"
Height="32"
VerticalAlignment="Top">
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<GroupBox Header="Видео" Style="{StaticResource AddSettingsGroup}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Контейнер" Style="{StaticResource AddSettingsLabel}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="Видео codec" Style="{StaticResource AddSettingsLabel}" />
<ComboBox Grid.Row="1" Grid.Column="0" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding ContainerOptions}" SelectedItem="{Binding Container, Mode=TwoWay}" />
<ComboBox Grid.Row="1" Grid.Column="1" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding VideoCodecOptions}" SelectedItem="{Binding Video, Mode=TwoWay}" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Pixel format" Style="{StaticResource AddSettingsLabel}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="Resolution" Style="{StaticResource AddSettingsLabel}" />
<ComboBox Grid.Row="3" Grid.Column="0" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding PixelFormatOptions}" SelectedItem="{Binding PixelFormat, Mode=TwoWay}" />
<ComboBox Grid.Row="3" Grid.Column="1" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding ResolutionOptions}" SelectedItem="{Binding Resolution, Mode=TwoWay}" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="FPS" Style="{StaticResource AddSettingsLabel}" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="Видеобитрейт" Style="{StaticResource AddSettingsLabel}" />
<ComboBox Grid.Row="5" Grid.Column="0" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding FpsOptions}" SelectedItem="{Binding Fps, Mode=TwoWay}" />
<Grid Grid.Row="5" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="86" />
</Grid.ColumnDefinitions>
<ComboBox Grid.Column="0" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding VideoBitrateOptions}" SelectedItem="{Binding VideoBitrateMode, Mode=TwoWay}" />
<Grid Grid.Column="1" Visibility="{Binding IsVideoBitrateCustomVisible, Converter={StaticResource BoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Style="{StaticResource AddSettingsTextBox}"
Text="{Binding VideoBitrateCustomMbps, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</Grid>
</Grid>
</GroupBox>
<GroupBox Header="Аудио" Style="{StaticResource AddSettingsGroup}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Audio codec" Style="{StaticResource AddSettingsLabel}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="Audio bitrate" Style="{StaticResource AddSettingsLabel}" />
<ComboBox Grid.Row="1" Grid.Column="0" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding AudioCodecOptions}" SelectedItem="{Binding Audio, Mode=TwoWay}" />
<ComboBox Grid.Row="1" Grid.Column="1" Style="{StaticResource AddSettingsCombo}" ItemsSource="{Binding AudioBitrateOptions}" SelectedItem="{Binding AudioBitrate, Mode=TwoWay}" />
</Grid>
</GroupBox>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<GroupBox Grid.Column="0" Header="Внешние дорожки" Style="{StaticResource AddSettingsGroup}">
<WrapPanel>
<CheckBox Content="Внешние аудио" IsChecked="{Binding ExternalTracks, Mode=TwoWay}" Margin="0,0,14,4" />
<CheckBox Content="Внешние субтитры" IsChecked="{Binding ExternalSubtitles, Mode=TwoWay}" Margin="0,0,14,4" />
<CheckBox Content="Внешние шрифты" IsChecked="{Binding Fonts, Mode=TwoWay}" Margin="0,0,14,4" />
</WrapPanel>
</GroupBox>
<GroupBox Grid.Column="2" Header="Прочие настройки" Style="{StaticResource AddSettingsGroup}">
<WrapPanel>
<CheckBox Content="Удалять иностранные дорожки" IsChecked="{Binding RemoveForeignTracks, Mode=TwoWay}" Margin="0,0,14,4" />
<CheckBox Content="Отключать субтитры" IsChecked="{Binding DisableSubtitleDefault, Mode=TwoWay}" Margin="0,0,14,4" />
</WrapPanel>
</GroupBox>
</Grid>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="2" Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="18" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="24" />
<ColumnDefinition Width="18" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0"
Width="18"
Height="18"
Margin="0"
VerticalAlignment="Center"
IsChecked="{Binding DisableSubtitleDefault, Mode=TwoWay}" />
<TextBlock Grid.Column="1"
Text="Отключать субтитры"
<TextBlock Grid.Column="0"
Text="{Binding ValidationMessage}"
Foreground="{DynamicResource Ui.Brush.ErrorText}"
VerticalAlignment="Center"
Margin="10,0,0,0"
FontSize="12" />
<CheckBox Grid.Column="3"
Width="18"
Height="18"
Margin="0"
VerticalAlignment="Center"
IsChecked="{Binding RemoveForeignAudioAndSubtitles, Mode=TwoWay}" />
<TextBlock Grid.Column="4"
Text="Удалять иностранные дорожки"
VerticalAlignment="Center"
Margin="10,0,0,0"
FontSize="12" />
Visibility="{Binding HasValidationMessage, Converter={StaticResource BoolToVis}}" />
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button MinWidth="96"
Margin="0,0,8,0"
Style="{StaticResource UiButtonPrimary}"
Foreground="White"
Content="Добавить"
IsDefault="True"
Command="{Binding AddCommand}" />
<Button MinWidth="96"
Style="{StaticResource UiButtonSecondary}"
Content="Отмена"
IsCancel="True"
Command="{Binding CancelCommand}" />
</StackPanel>
</Grid>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button MinWidth="100"
Margin="0,0,8,0"
Style="{StaticResource UiButtonPrimary}"
Foreground="White"
Content="Добавить"
IsDefault="True"
Command="{Binding AddCommand}" />
<Button MinWidth="100"
Style="{StaticResource UiButtonSecondary}"
Content="Отмена"
IsCancel="True"
Command="{Binding CancelCommand}" />
</StackPanel>
</Grid>
</Window>