From 2e6c26178f2416356738b438ee5efca1f7f91577 Mon Sep 17 00:00:00 2001 From: Emby Toolbox Date: Sat, 16 May 2026 20:28:49 +0500 Subject: [PATCH] Add effective profile editor for queued files --- EmbyToolbox/Models/AddFilesOptions.cs | 1 + EmbyToolbox/Models/ConversionQueueItem.cs | 17 + .../Models/EffectiveProfileSettings.cs | 12 + .../Services/ConversionExecutionService.cs | 4 +- .../Services/ProfileOverrideBuilder.cs | 65 ++++ EmbyToolbox/Services/QueueAnalysisService.cs | 9 +- EmbyToolbox/Services/VideoBitratePolicy.cs | 1 + .../ViewModels/AddFilesOptionsViewModel.cs | 102 ----- .../ViewModels/AddFilesSettingsViewModel.cs | 357 ++++++++++++++++++ .../ViewModels/ConversionFormOptions.cs | 3 +- EmbyToolbox/ViewModels/ConversionViewModel.cs | 49 ++- EmbyToolbox/Views/AddFilesOptionsDialog.xaml | 212 +++++++---- 12 files changed, 657 insertions(+), 175 deletions(-) create mode 100644 EmbyToolbox/Models/EffectiveProfileSettings.cs create mode 100644 EmbyToolbox/Services/ProfileOverrideBuilder.cs delete mode 100644 EmbyToolbox/ViewModels/AddFilesOptionsViewModel.cs create mode 100644 EmbyToolbox/ViewModels/AddFilesSettingsViewModel.cs 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/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..412bbee --- /dev/null +++ b/EmbyToolbox/ViewModels/AddFilesSettingsViewModel.cs @@ -0,0 +1,357 @@ +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 _subtitles; + 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 Subtitles { get => _subtitles; set => SetField(ref _subtitles, 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, + Subtitles = Subtitles, + 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; + Subtitles = IsYes(profile.Subtitles); + 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("Subtitles")) Subtitles = state.Subtitles; + 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 = ToYesNo(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 Subtitles { get; init; } + 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/Views/AddFilesOptionsDialog.xaml b/EmbyToolbox/Views/AddFilesOptionsDialog.xaml index b095dbd..8bfa7ce 100644 --- a/EmbyToolbox/Views/AddFilesOptionsDialog.xaml +++ b/EmbyToolbox/Views/AddFilesOptionsDialog.xaml @@ -1,96 +1,182 @@ - + + + + + + + + + - - - + + - - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + - - - - - - + Visibility="{Binding HasValidationMessage, Converter={StaticResource BoolToVis}}" /> + +