diff --git a/EmbyToolbox/App.xaml.cs b/EmbyToolbox/App.xaml.cs index 83a43ed..7d973a5 100644 --- a/EmbyToolbox/App.xaml.cs +++ b/EmbyToolbox/App.xaml.cs @@ -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); } } - diff --git a/EmbyToolbox/Interop/ToastShortcutRegistration.cs b/EmbyToolbox/Interop/ToastShortcutRegistration.cs new file mode 100644 index 0000000..ebe05db --- /dev/null +++ b/EmbyToolbox/Interop/ToastShortcutRegistration.cs @@ -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); + } +} diff --git a/EmbyToolbox/MainWindow.xaml b/EmbyToolbox/MainWindow.xaml index 98f65e8..59a650b 100644 --- a/EmbyToolbox/MainWindow.xaml +++ b/EmbyToolbox/MainWindow.xaml @@ -368,7 +368,6 @@ - @@ -412,37 +411,6 @@ - - - - - - - - - @@ -463,43 +431,6 @@ - - - @@ -541,9 +472,21 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + @@ -596,40 +539,97 @@ + + + + + + + + + + + + + + + - - - - + + + + + + + + - - - + + + - + - + + - @@ -645,107 +645,139 @@ - + - + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + ScrollViewer.VerticalScrollBarVisibility="Auto" + MinHeight="0"> + + + + + @@ -884,19 +916,6 @@ - - - - - - - - - - - - @@ -937,50 +956,44 @@ - - - - - - - - + - - - - + - + + + + + + + diff --git a/EmbyToolbox/MainWindow.xaml.cs b/EmbyToolbox/MainWindow.xaml.cs index 8acdfce..811ee44 100644 --- a/EmbyToolbox/MainWindow.xaml.cs +++ b/EmbyToolbox/MainWindow.xaml.cs @@ -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(); - } - } } diff --git a/EmbyToolbox/Models/AddFilesOptions.cs b/EmbyToolbox/Models/AddFilesOptions.cs index 15d4736..a40a293 100644 --- a/EmbyToolbox/Models/AddFilesOptions.cs +++ b/EmbyToolbox/Models/AddFilesOptions.cs @@ -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(); } diff --git a/EmbyToolbox/Models/ConversionQueueItem.cs b/EmbyToolbox/Models/ConversionQueueItem.cs index f247af8..74d4993 100644 --- a/EmbyToolbox/Models/ConversionQueueItem.cs +++ b/EmbyToolbox/Models/ConversionQueueItem.cs @@ -24,6 +24,7 @@ public sealed class ConversionQueueItem : INotifyPropertyChanged private MediaAnalysisResult? _mediaAnalysis; private IReadOnlyList _sidecars = System.Array.Empty(); private IReadOnlyList _externalAudioFiles = System.Array.Empty(); + 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(); } } diff --git a/EmbyToolbox/Models/EffectiveProfileSettings.cs b/EmbyToolbox/Models/EffectiveProfileSettings.cs new file mode 100644 index 0000000..2b3f3a2 --- /dev/null +++ b/EmbyToolbox/Models/EffectiveProfileSettings.cs @@ -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 ChangedFields { get; init; } = []; + + public bool HasOverrides => ChangedFields.Count > 0; +} diff --git a/EmbyToolbox/Services/ConversionExecutionService.cs b/EmbyToolbox/Services/ConversionExecutionService.cs index 811f5ee..fecd9e9 100644 --- a/EmbyToolbox/Services/ConversionExecutionService.cs +++ b/EmbyToolbox/Services/ConversionExecutionService.cs @@ -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)}"); diff --git a/EmbyToolbox/Services/NotificationService.cs b/EmbyToolbox/Services/NotificationService.cs index 9dad4d5..8db34f5 100644 --- a/EmbyToolbox/Services/NotificationService.cs +++ b/EmbyToolbox/Services/NotificationService.cs @@ -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"); diff --git a/EmbyToolbox/Services/ProfileOverrideBuilder.cs b/EmbyToolbox/Services/ProfileOverrideBuilder.cs new file mode 100644 index 0000000..e4f9cec --- /dev/null +++ b/EmbyToolbox/Services/ProfileOverrideBuilder.cs @@ -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(); + 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 changed, string name, string? source, string? value) + { + if (!string.Equals(source?.Trim(), value?.Trim(), StringComparison.Ordinal)) + { + changed.Add(name); + } + } +} diff --git a/EmbyToolbox/Services/QueueAnalysisService.cs b/EmbyToolbox/Services/QueueAnalysisService.cs index 1f866e4..b88b8c0 100644 --- a/EmbyToolbox/Services/QueueAnalysisService.cs +++ b/EmbyToolbox/Services/QueueAnalysisService.cs @@ -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)) diff --git a/EmbyToolbox/Services/VideoBitratePolicy.cs b/EmbyToolbox/Services/VideoBitratePolicy.cs index 4dbc702..69e4c24 100644 --- a/EmbyToolbox/Services/VideoBitratePolicy.cs +++ b/EmbyToolbox/Services/VideoBitratePolicy.cs @@ -15,6 +15,7 @@ public static class VideoBitratePolicy Auto, Source, "2 Mbps", + "3 Mbps", "4 Mbps", "6 Mbps", "8 Mbps", diff --git a/EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs b/EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs deleted file mode 100644 index 6823e6e..0000000 --- a/EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs +++ /dev/null @@ -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 _onAdd; - private readonly Action _onCancel; - - private bool _disableSubtitleDefault; - private bool _removeForeignAudioAndSubtitles; - private ConversionProfilePresetRow? _selectedProfile; - - public AddFilesOptionsViewModel( - IReadOnlyList profiles, - string selectedProfileName, - bool disableSubtitleDefault, - bool removeForeignAudioAndSubtitles, - Action onAdd, - Action onCancel) - { - _onAdd = onAdd; - _onCancel = onCancel; - _disableSubtitleDefault = disableSubtitleDefault; - _removeForeignAudioAndSubtitles = removeForeignAudioAndSubtitles; - Profiles = new ObservableCollection(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 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)); -} diff --git a/EmbyToolbox/ViewModels/AddFilesSettingsViewModel.cs b/EmbyToolbox/ViewModels/AddFilesSettingsViewModel.cs new file mode 100644 index 0000000..56d4032 --- /dev/null +++ b/EmbyToolbox/ViewModels/AddFilesSettingsViewModel.cs @@ -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 _profiles; + private readonly Action _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 profiles, + ConversionFormOptions formOptions, + AddFilesSettingsState? previousState, + string fallbackProfileName, + bool fallbackDisableSubtitleDefault, + bool fallbackRemoveForeignTracks, + Action onAdd, + Action onCancel) + { + _profiles = profiles; + _onAdd = onAdd; + _onCancel = onCancel; + Profiles = new ObservableCollection(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 Profiles { get; } + public IReadOnlyList ContainerOptions { get; } + public IReadOnlyList VideoCodecOptions { get; } + public IReadOnlyList PixelFormatOptions { get; } + public IReadOnlyList ResolutionOptions { get; } + public IReadOnlyList FpsOptions { get; } + public IReadOnlyList AudioCodecOptions { get; } + public IReadOnlyList AudioBitrateOptions { get; } + public IReadOnlyList VideoBitrateOptions { get; } + public IReadOnlyList 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(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.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 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; } +} diff --git a/EmbyToolbox/ViewModels/ConversionFormOptions.cs b/EmbyToolbox/ViewModels/ConversionFormOptions.cs index fcbed94..97c7e87 100644 --- a/EmbyToolbox/ViewModels/ConversionFormOptions.cs +++ b/EmbyToolbox/ViewModels/ConversionFormOptions.cs @@ -11,7 +11,8 @@ public sealed class ConversionFormOptions public List PixelFormatOptions { get; } = ["yuv420p", "yuv420p10le", "yuv422p", "yuv444p"]; public List ResolutionOptions { get; } = ["Без изменений", "Максимум 2160p", "Максимум 1440p", "Максимум 1080p", "Максимум 720p"]; public List FpsOptions { get; } = ["Без изменений", "Максимум 60", "Максимум 30", "Максимум 25", "Максимум 24"]; - public List AudioBitrateKbps { get; } = ["128 kbps", "192 kbps", "256 kbps", "320 kbps"]; + public List AudioCodecOptions { get; } = ["AAC", "AC3", "EAC3", "Opus", "MP3", "FLAC", "Copy"]; + public List AudioBitrateKbps { get; } = ["96 kbps", "128 kbps", "160 kbps", "192 kbps", "256 kbps", "320 kbps"]; public List VideoBitrateModeOptions { get; } = VideoBitratePolicy.UiOptions.ToList(); internal void RestoreListsFromSerialized(IReadOnlyList? containers, diff --git a/EmbyToolbox/ViewModels/ConversionViewModel.cs b/EmbyToolbox/ViewModels/ConversionViewModel.cs index 95b8525..21953bd 100644 --- a/EmbyToolbox/ViewModels/ConversionViewModel.cs +++ b/EmbyToolbox/ViewModels/ConversionViewModel.cs @@ -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(); @@ -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); } diff --git a/EmbyToolbox/ViewModels/VideoInfoViewModel.cs b/EmbyToolbox/ViewModels/VideoInfoViewModel.cs index 92d747c..66c0811 100644 --- a/EmbyToolbox/ViewModels/VideoInfoViewModel.cs +++ b/EmbyToolbox/ViewModels/VideoInfoViewModel.cs @@ -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 _formattedJsonLines = Array.Empty(); 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 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 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 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 SplitTextLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return Array.Empty(); + } + + return text.ReplaceLineEndings("\n").Split('\n'); + } } diff --git a/EmbyToolbox/Views/AddFilesOptionsDialog.xaml b/EmbyToolbox/Views/AddFilesOptionsDialog.xaml index b095dbd..54083cf 100644 --- a/EmbyToolbox/Views/AddFilesOptionsDialog.xaml +++ b/EmbyToolbox/Views/AddFilesOptionsDialog.xaml @@ -1,96 +1,178 @@ - + + + + + + + + + - - - + + - - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + - - - - - - + Visibility="{Binding HasValidationMessage, Converter={StaticResource BoolToVis}}" /> + +