using System.Collections.ObjectModel; using System.Collections; using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Input; using System.Windows.Threading; using EmbyToolbox.Models; using EmbyToolbox.Services; namespace EmbyToolbox.ViewModels; public sealed class FileConversionSettingsViewModel : INotifyPropertyChanged, ITrackPlanPreviewHost { private readonly ConversionQueueItem _item; private readonly ConversionTaskOverride _draft; private readonly ConversionPlanService _planner; private readonly IProfileSettingsProvider _profile; private readonly TrackSettingsSnapshotService _snapshotService; private readonly LoggingService _logging; private readonly Action _saveAndCloseAction; private string _planPreview = string.Empty; private string _container = string.Empty; private string _video = string.Empty; private string _pixel = string.Empty; private string _resolution = string.Empty; private string _fps = string.Empty; private string _videoBitrateMode = VideoBitratePolicy.Auto; private string _videoBitrateCustomMbps = string.Empty; private string _currentFileVideoBitrate = "Текущее: неизвестно"; private string _bulkTrackType = "Все"; private TrackActionKind? _bulkActionValue; private string? _bulkBitrateValue; private bool _isSyncingBulkFromSelection; private bool _isBulkBitrateEnabled; private string? _fileContainer; private string? _fileVideo; private string? _filePixel; private string? _fileResolution; private string? _fileFps; private bool _isAutoAppliedFromSnapshot; private string _snapshotStatusText = string.Empty; private string _toastMessage = string.Empty; private bool _isToastVisible; private ToastKind _toastKind; private DispatcherTimer? _toastHideTimer; private const double ToastDismissSecondsMin = 2.0; private const double ToastDismissSecondsMax = 3.0; /// Порог длины текста (символы): дольше — показ до с. private const int ToastLongMessageCharThreshold = 56; /// Строки для toast (кратко и заметно); подробности — в статусе внизу. private const string SnapshotToastAppliedFull = "Настройки предыдущего файла применены"; private const string SnapshotToastAppliedPartial = "Частично применены настройки предыдущего файла"; /// Единый текст toast при любой неудаче автоприменения snapshot. private const string SnapshotToastNotApplied = "Настройки предыдущего файла не применены"; private const string SnapshotStatusStructureMismatch = "Настройки предыдущего файла не применены: структура дорожек отличается"; public FileConversionSettingsViewModel( ConversionQueueItem item, ConversionPlanService planner, IProfileSettingsProvider profile, TrackSettingsSnapshotService snapshotService, LoggingService logging, ConversionFormOptions formOptions, bool copyPreviousTrackSettings, Action onSaveApplied, Action onClose) { _item = item; _planner = planner; _profile = profile; _snapshotService = snapshotService; _logging = logging; _draft = item.TaskOverride.Clone(); _saveAndCloseAction = () => { if (_item.MediaAnalysis is null) { return; } if (!ValidateBeforeSave(showMessage: true)) { return; } var hasChangesFromCurrent = !OverridesEquivalent(_draft, _item.TaskOverride); var isManual = IsDraftDifferentFromAutoPlan(); _item.TaskOverride.CopyFrom(_draft); var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; var plan = _planner.Build(_item.MediaAnalysis, _item.Sidecars, prof, _item.TaskOverride, _item.ExternalAudioFiles); _item.IsManuallyEdited = isManual; _item.SetPlan(plan); SaveCurrentSnapshot(); onSaveApplied(_item, hasChangesFromCurrent); onClose(); }; FormOptions = formOptions; FilePath = item.FullPath; ProfileName = item.Profile; FillFileActuals(); _container = _draft.TargetContainer; _video = _draft.TargetVideo; _pixel = _draft.TargetPixelFormat; _resolution = _draft.TargetResolution; _fps = _draft.TargetFps; _videoBitrateMode = string.IsNullOrWhiteSpace(_draft.TargetVideoBitrateMode) ? VideoBitratePolicy.Auto : _draft.TargetVideoBitrateMode; _videoBitrateCustomMbps = _draft.TargetVideoBitrateMbps?.ToString("0.###", CultureInfo.InvariantCulture) ?? string.Empty; _planPreview = item.LastPlan?.ShortSummary ?? item.PlanSummary; RebuildRowsFromDraft(); if (copyPreviousTrackSettings) { TryApplyPreviousSnapshot(); } else { OpenWithAutoPlanOnly(); } ValidateDefaultConflicts(); RecalculatePlanPreview(); SaveCommand = new RelayCommand(() => _saveAndCloseAction(), () => _item.MediaAnalysis is not null); SaveAndCloseCommand = new RelayCommand( ExecuteSaveAndCloseFromHotkey, CanExecuteSaveAndCloseFromHotkey); CancelCommand = new RelayCommand(onClose); UndoAutoApplyCommand = new RelayCommand(ExecuteUndoAutoApply, () => IsAutoAppliedFromSnapshot); RemoveForeignTracksCommand = new RelayCommand(RemoveForeignTracks); MarkForeignTracksForRemovalCommand = RemoveForeignTracksCommand; SetSelectedTracksRemoveCommand = new RelayCommand(SetSelectedTracksRemove, CanSetSelectedTracksRemove); OnSelectionChangedCommand = new RelayCommand(OnSelectionChanged); ContextSetActionCommand = new RelayCommand(ApplyContextAction, CanApplyContextAction); ContextSetBitrateCommand = new RelayCommand(ApplyContextBitrate, CanApplyContextBitrate); PlayFileCommand = new RelayCommand(ExecutePlayFile, CanExecutePlayFile); CloseToastCommand = new RelayCommand(ExecuteCloseToast); } public string BulkTrackType { get => _bulkTrackType; set { if (_bulkTrackType == value) { return; } _bulkTrackType = value; OnPropertyChanged(); SyncBulkControlsFromSelection(); } } public TrackActionKind? BulkActionValue { get => _bulkActionValue; set { if (_bulkActionValue == value) { return; } _bulkActionValue = value; OnPropertyChanged(); if (!_isSyncingBulkFromSelection && value is { } action) { ApplyBulkActionOnChange(action); } } } public string? BulkBitrateValue { get => _bulkBitrateValue; set { if (_bulkBitrateValue == value) { return; } _bulkBitrateValue = value; OnPropertyChanged(); if (!_isSyncingBulkFromSelection && !string.IsNullOrWhiteSpace(value)) { ApplyBulkBitrateOnChange(value); } } } public ConversionFormOptions FormOptions { get; } public string FilePath { get; } public string ProfileName { get; } public ObservableCollection TrackRows { get; } = new(); public ObservableCollection SelectedTracks { get; } = new(); public string? CurrentFileContainer => _fileContainer; public string? CurrentFileVideo => _fileVideo; public string? CurrentFilePixel => _filePixel; public string? CurrentFileResolution => _fileResolution; public string? CurrentFileFps => _fileFps; public string CurrentFileVideoBitrate => _currentFileVideoBitrate; public IReadOnlyList VideoBitrateOptions => FormOptions.VideoBitrateModeOptions; public bool IsVideoBitrateCustomVisible => string.Equals(TargetVideoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal); public ICommand SaveCommand { get; } public RelayCommand SaveAndCloseCommand { get; } public ICommand CancelCommand { get; } public RelayCommand UndoAutoApplyCommand { get; } public ICommand RemoveForeignTracksCommand { get; } public ICommand MarkForeignTracksForRemovalCommand { get; } public RelayCommand SetSelectedTracksRemoveCommand { get; } public ICommand OnSelectionChangedCommand { get; } public RelayCommand ContextSetActionCommand { get; } public RelayCommand ContextSetBitrateCommand { get; } public RelayCommand PlayFileCommand { get; } public RelayCommand CloseToastCommand { get; } /// Подсказка для кнопки воспроизведения (файл существует / нет). public string PlayFileToolTip => CanExecutePlayFile(FilePath) ? "Воспроизвести файл" : "Файл не найден"; public IReadOnlyList BulkTrackTypeOptions { get; } = ["Все", "Видео", "Аудио", "Субтитры", "Attachments"]; public IReadOnlyList BulkActionOptions { get; } = [TrackActionKind.Keep, TrackActionKind.Convert, TrackActionKind.Remove, TrackActionKind.Add]; public IReadOnlyList BulkBitrateOptions => FormOptions.AudioBitrateKbps; public bool IsBulkBitrateEnabled { get => _isBulkBitrateEnabled; private set { if (_isBulkBitrateEnabled == value) { return; } _isBulkBitrateEnabled = value; OnPropertyChanged(); } } public bool IsAutoAppliedFromSnapshot { get => _isAutoAppliedFromSnapshot; private set { if (_isAutoAppliedFromSnapshot == value) { return; } _isAutoAppliedFromSnapshot = value; OnPropertyChanged(); UndoAutoApplyCommand?.RaiseCanExecuteChanged(); } } public string SnapshotStatusText { get => _snapshotStatusText; private set { if (_snapshotStatusText == value) { return; } _snapshotStatusText = value; OnPropertyChanged(); } } public string ToastMessage { get => _toastMessage; private set { if (_toastMessage == value) { return; } _toastMessage = value; OnPropertyChanged(); } } public bool IsToastVisible { get => _isToastVisible; private set { if (_isToastVisible == value) { return; } _isToastVisible = value; OnPropertyChanged(); } } public ToastKind ToastKind { get => _toastKind; private set { if (_toastKind == value) { return; } _toastKind = value; OnPropertyChanged(); OnPropertyChanged(nameof(ToastIconGlyph)); } } public string ToastIconGlyph => ToastKind switch { ToastKind.Success => "\uE73E", ToastKind.Warning => "\uE814", ToastKind.Error => "\uEA39", _ => "\uE946" }; public void ShowToast(string message, ToastKind kind) { if (string.IsNullOrWhiteSpace(message)) { return; } StopToastHideTimer(); var trimmed = message.Trim(); ToastMessage = trimmed; ToastKind = kind; IsToastVisible = true; var sec = ResolveToastDismissSeconds(trimmed); var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; _toastHideTimer = new DispatcherTimer( TimeSpan.FromSeconds(sec), DispatcherPriority.Background, OnToastHideTick, dispatcher); } private static double ResolveToastDismissSeconds(string message) => message.Length > ToastLongMessageCharThreshold ? ToastDismissSecondsMax : ToastDismissSecondsMin + (ToastDismissSecondsMax - ToastDismissSecondsMin) * (message.Length / (double)ToastLongMessageCharThreshold); private void OnToastHideTick(object? sender, EventArgs e) { HideToastInstant(); } private void ExecuteCloseToast() { if (!IsToastVisible) { return; } HideToastInstant(); } private void HideToastInstant() { StopToastHideTimer(); IsToastVisible = false; ToastMessage = string.Empty; } private void StopToastHideTimer() { if (_toastHideTimer is null) { return; } _toastHideTimer.Stop(); _toastHideTimer = null; } public string PlanPreview { get => _planPreview; private set { if (_planPreview == value) { return; } _planPreview = value; OnPropertyChanged(); } } public string TargetContainer { get => _container; set { if (_container == value) { return; } _container = value; _draft.TargetContainer = value; OnPropertyChanged(); foreach (var row in TrackRows) { row.RefreshSubtitleDetails(_container); } RecalculatePlanPreview(); } } public string TargetVideo { get => _video; set { if (_video == value) { return; } _video = value; _draft.TargetVideo = value; OnPropertyChanged(); RecalculatePlanPreview(); } } public string TargetPixelFormat { get => _pixel; set { if (_pixel == value) { return; } _pixel = value; _draft.TargetPixelFormat = value; OnPropertyChanged(); RecalculatePlanPreview(); } } public string TargetResolution { get => _resolution; set { if (_resolution == value) { return; } _resolution = value; _draft.TargetResolution = value; OnPropertyChanged(); RecalculatePlanPreview(); } } public string TargetFps { get => _fps; set { if (_fps == value) { return; } _fps = value; _draft.TargetFps = value; OnPropertyChanged(); RecalculatePlanPreview(); } } public string TargetVideoBitrateMode { get => _videoBitrateMode; set { var next = string.IsNullOrWhiteSpace(value) ? VideoBitratePolicy.Auto : value.Trim(); if (_videoBitrateMode == next) { return; } _videoBitrateMode = next; _draft.TargetVideoBitrateMode = next; OnPropertyChanged(); OnPropertyChanged(nameof(IsVideoBitrateCustomVisible)); RecalculatePlanPreview(); } } public string VideoBitrateCustomMbps { get => _videoBitrateCustomMbps; set { if (_videoBitrateCustomMbps == value) { return; } _videoBitrateCustomMbps = value; OnPropertyChanged(); if (TryParseCustomVideoBitrate(value, out var mbps)) { _draft.TargetVideoBitrateMbps = mbps; } else { _draft.TargetVideoBitrateMbps = null; } RecalculatePlanPreview(); } } public void RecalculatePlanPreview() { if (_item.MediaAnalysis is not { } m) { PlanPreview = "—"; return; } _draft.TargetContainer = _container; _draft.TargetVideo = _video; _draft.TargetPixelFormat = _pixel; _draft.TargetResolution = _resolution; _draft.TargetFps = _fps; _draft.TargetVideoBitrateMode = _videoBitrateMode; _draft.TargetVideoBitrateMbps = string.Equals(_videoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal) && TryParseCustomVideoBitrate(_videoBitrateCustomMbps, out var mbps) ? mbps : null; ValidateDefaultConflicts(); var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; var plan = _planner.Build(m, _item.Sidecars, prof, _draft, _item.ExternalAudioFiles); PlanPreview = plan.ShortSummary; } public void OnTrackDefaultEnabled(TrackSettingsRowViewModel row) { if (row.DataModel.StreamKind is not (MediaStreamKind.Audio or MediaStreamKind.Subtitle)) { return; } foreach (var r in TrackRows) { if (ReferenceEquals(r, row)) { continue; } if (r.DataModel.StreamKind == row.DataModel.StreamKind) { r.Default = false; } } } public void ValidateDefaultConflicts() { var audioDefaults = TrackRows .Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio && r.Action != TrackActionKind.Remove && r.Default is true) .ToList(); var subDefaults = TrackRows .Where(r => r.DataModel.StreamKind == MediaStreamKind.Subtitle && r.Action != TrackActionKind.Remove && r.Default is true) .ToList(); foreach (var r in TrackRows) { r.HasDefaultConflict = false; } if (audioDefaults.Count > 1) { foreach (var r in audioDefaults) { r.HasDefaultConflict = true; } } if (subDefaults.Count > 1) { foreach (var r in subDefaults) { r.HasDefaultConflict = true; } } } public event PropertyChangedEventHandler? PropertyChanged; private bool CanExecuteSaveAndCloseFromHotkey() { if (_item.MediaAnalysis is null) { return false; } if (Keyboard.FocusedElement is System.Windows.Controls.ComboBox cb && cb.IsDropDownOpen) { return false; } return true; } private void ExecuteSaveAndCloseFromHotkey() { _logging.Debug("Настройки дорожек сохранены через Ctrl+Enter", "conversion.keyboard"); _saveAndCloseAction(); } private void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); private void FillFileActuals() { var m = _item.MediaAnalysis; if (m is null) { return; } _fileContainer = NormalizeContainerForUi(m.ContainerFormat ?? m.FormatName, _item.FullPath); _currentFileVideoBitrate = VideoBitratePolicy.FormatCurrentSource(m.SourceVideoBitrateBps); if (m.PrimaryVideo is { } v) { _fileVideo = v.CodecName; _filePixel = v.PixelFormat; if (v.Width is { } w && v.Height is { } h) { _fileResolution = $"{w}x{h}"; } if (v.FrameRate is { } f) { _fileFps = f.ToString("0.###", CultureInfo.InvariantCulture); } } } private void RebuildRowsFromDraft() { TrackRows.Clear(); var media = _item.MediaAnalysis; var n = 1; foreach (var t in _draft.TrackOverrides) { MediaStreamInfo? em = t.Source == SourceKind.Embedded && media is not null ? media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex) : null; TrackRows.Add(new TrackSettingsRowViewModel(this, t, n, em, _container)); n++; } } /// Без snapshot: черновик уже из сохранённого состояния файла и автоплана анализа; только сброс UI статуса snapshot. private void OpenWithAutoPlanOnly() { IsAutoAppliedFromSnapshot = false; SnapshotStatusText = string.Empty; HideToastInstant(); } private void TryApplyPreviousSnapshot() { var current = BuildSnapshotItemsFromDraft(); var undRisk = TrackSettingsSnapshotService.TracksHaveRiskyMultipleUndTracks(current); var applyResult = _snapshotService.TryApplySnapshot(current, _item.FullPath, _item.SnapshotScopeBatchRoot); if (applyResult.Reason == SnapshotApplyReason.NoSnapshot) { return; } if (applyResult.Reason == SnapshotApplyReason.ScopeMismatch) { SnapshotStatusText = "Настройки из другого каталога или пакета — не применены."; ShowToast(SnapshotToastNotApplied, ToastKind.Error); return; } if (!applyResult.AppliedAny || applyResult.TrackResults is null) { SnapshotStatusText = SnapshotStatusStructureMismatch; ShowToast(SnapshotToastNotApplied, ToastKind.Error); return; } var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; var appliedRows = 0; foreach (var row in TrackRows) { var mr = applyResult.TrackResults.FirstOrDefault(r => r.CurrentOrder == row.IndexDisplay); if (mr is not { IsMatched: true, SourceItem: { } src }) { continue; } if (!TryApplyMatchedSnapshotToRow(row, src, prof)) { continue; } row.AudioBitrateKbps = src.Bitrate; row.Default = src.Default; if (src.SnapshotTitleWasUserEdited && !string.IsNullOrWhiteSpace(src.Title)) { row.Title = src.Title.Trim(); } row.Language = string.IsNullOrWhiteSpace(src.Language) ? null : src.Language.Trim(); appliedRows++; } if (appliedRows == 0) { SnapshotStatusText = SnapshotStatusStructureMismatch; ShowToast(SnapshotToastNotApplied, ToastKind.Error); return; } IsAutoAppliedFromSnapshot = true; var undSuffix = undRisk ? " Snapshot применён к und-дорожкам по порядку внутри группы (риск перепутать)." : string.Empty; var totalRows = TrackRows.Count; ToastKind toastKind; string toastCopy; string statusLinePartial; switch (applyResult.Degree) { case SnapshotApplyDegree.Full: toastKind = ToastKind.Success; toastCopy = SnapshotToastAppliedFull; statusLinePartial = "Применены настройки предыдущего файла"; break; case SnapshotApplyDegree.Partial: toastKind = ToastKind.Warning; toastCopy = SnapshotToastAppliedPartial; statusLinePartial = "Частично применены настройки предыдущего файла"; var matchedInKeys = applyResult.TrackResults.Count(r => r.IsMatched); _logging.Info($"Совпало {matchedInKeys} из {totalRows} дорожек", "conversion.snapshot"); break; default: toastKind = ToastKind.Error; toastCopy = SnapshotToastNotApplied; statusLinePartial = SnapshotStatusStructureMismatch; break; } SnapshotStatusText = statusLinePartial + undSuffix; ShowToast(toastCopy, toastKind); ValidateDefaultConflicts(); RecalculatePlanPreview(); } private bool TryApplyMatchedSnapshotToRow( TrackSettingsRowViewModel row, TrackSettingsSnapshotItem src, ConversionProfileSettingsEntry profile) { var entry = row.DataModel; if (entry.StreamKind != src.StreamKind || entry.Source != src.Source) { return false; } var action = src.Action; if (action == TrackActionKind.Add && entry.Source != SourceKind.External) { return false; } if (action == TrackActionKind.Remove && entry.StreamKind == MediaStreamKind.Video) { return false; } if (action == TrackActionKind.Convert && entry.Source == SourceKind.Embedded && _item.MediaAnalysis is { } media) { if (entry.StreamKind == MediaStreamKind.Audio && entry.StreamIndex >= 0) { var st = media.AllStreams.FirstOrDefault( s => s.Index == entry.StreamIndex && s.Kind == MediaStreamKind.Audio); if (st is not null && ConversionPlanService.EmbeddedAudioMatchesProfile(st.CodecName, profile)) { action = TrackActionKind.Keep; } } else if (entry.StreamKind == MediaStreamKind.Video && entry.StreamIndex >= 0) { var vst = media.AllStreams.FirstOrDefault( s => s.Index == entry.StreamIndex && s.Kind == MediaStreamKind.Video); if (vst is not null) { var targetV = string.IsNullOrWhiteSpace(_draft.TargetVideo) ? profile.Video : _draft.TargetVideo; if (ConversionPlanService.VideoCodecMatchesTarget(vst.CodecName, targetV)) { action = TrackActionKind.Keep; } } } } if (!row.ValidActions.Contains(action)) { return false; } row.Action = action; return true; } private void ExecuteUndoAutoApply() { if (_item.MediaAnalysis is null) { return; } var profile = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; var reset = new ConversionTaskOverride(); TrackOverrideSeeder.EnsureDefaults( reset, _item.MediaAnalysis, _item.Sidecars, profile, externalAudio: _item.ExternalAudioFiles, videoPath: _item.FullPath); _draft.CopyFrom(reset); RebuildRowsFromDraft(); IsAutoAppliedFromSnapshot = false; SnapshotStatusText = string.Empty; HideToastInstant(); ValidateDefaultConflicts(); RecalculatePlanPreview(); } private void SaveCurrentSnapshot() { var items = BuildSnapshotItemsFromDraft(); _snapshotService.SaveSnapshot(_item.FullPath, items, _item.SnapshotScopeBatchRoot); } private IReadOnlyList BuildSnapshotItemsFromDraft() { var list = new List(_draft.TrackOverrides.Count); var media = _item.MediaAnalysis; var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; var order = 1; foreach (var t in _draft.TrackOverrides) { var codec = ResolveCodecForSnapshot(t, media); list.Add(new TrackSettingsSnapshotItem { Order = order, StreamKind = t.StreamKind, Source = t.Source, Codec = codec, Language = (t.Language ?? string.Empty).Trim(), Title = (t.Title ?? string.Empty).Trim(), Action = t.Action, Bitrate = t.AudioBitrateKbps, Default = t.Default, TargetCodec = t.StreamKind switch { MediaStreamKind.Video => _draft.TargetVideo, MediaStreamKind.Audio => prof.Audio, _ => null }, SnapshotTitleWasUserEdited = IsSnapshotTitleEditedByUser(t, media) }); order++; } return list; } private bool IsSnapshotTitleEditedByUser(TrackOverrideEntry t, MediaAnalysisResult? media) { var canonical = CanonicalTitleFromSource(t, media); var a = TrackSettingsSnapshotService.NormalizeTitleFingerprint(t.Title); var b = TrackSettingsSnapshotService.NormalizeTitleFingerprint(canonical); return !string.Equals(a, b, StringComparison.Ordinal); } private string? CanonicalTitleFromSource(TrackOverrideEntry t, MediaAnalysisResult? media) { if (media is not null && t.Source == SourceKind.Embedded && t.StreamIndex >= 0) { var s = media.AllStreams.FirstOrDefault(x => x.Index == t.StreamIndex); return s?.Title; } if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalPath)) { return TrackOverrideSeeder.ExternalAudioCanonicalTitleFromEntry(_draft.TrackOverrides, t, _item.FullPath); } if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Subtitle && !string.IsNullOrWhiteSpace(t.ExternalPath)) { return TrackOverrideSeeder.ExternalSubtitleCanonicalTitle(_draft.TrackOverrides, t, _item.FullPath); } if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Attachment && !string.IsNullOrWhiteSpace(t.ExternalPath)) { return Path.GetFileName(t.ExternalPath); } if (t.Source == SourceKind.External && !string.IsNullOrWhiteSpace(t.ExternalPath)) { return Path.GetFileNameWithoutExtension(Path.GetFileName(t.ExternalPath)); } return string.Empty; } private static string ResolveCodecForSnapshot(TrackOverrideEntry t, MediaAnalysisResult? media) { if (t.Source == SourceKind.Embedded && media is not null && t.StreamIndex >= 0) { var src = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex); if (!string.IsNullOrWhiteSpace(src?.CodecName)) { return src!.CodecName; } } if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalStreamCodec)) { return t.ExternalStreamCodec.Trim(); } if (t.Source == SourceKind.External && !string.IsNullOrWhiteSpace(t.ExternalPath)) { var ext = Path.GetExtension(t.ExternalPath); if (!string.IsNullOrWhiteSpace(ext)) { return ext.TrimStart('.').ToLowerInvariant(); } } return string.Empty; } private void RemoveForeignTracks() { foreach (var row in TrackRows) { var t = row.DataModel; if (t.Source != SourceKind.Embedded) { continue; } if (t.StreamKind is not (MediaStreamKind.Audio or MediaStreamKind.Subtitle)) { continue; } if (string.IsNullOrWhiteSpace(t.Language)) { continue; } var lang = t.Language!.Trim().ToLowerInvariant(); if (lang is "und" or "unknown" or "?") { continue; } if (lang is "rus" or "ru") { continue; } row.Action = TrackActionKind.Remove; } ValidateDefaultConflicts(); RecalculatePlanPreview(); } private bool ValidateBeforeSave(bool showMessage) { ValidateDefaultConflicts(); var audioConflicts = TrackRows.Count(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Audio); var subConflicts = TrackRows.Count(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Subtitle); if (audioConflicts > 0 || subConflicts > 0) { if (showMessage) { MessageBox.Show( "Ошибка: для Audio и Subtitle может быть только одна дорожка Default (исключая Remove).", "Валидация", MessageBoxButton.OK, MessageBoxImage.Warning); } return false; } if (string.Equals(_videoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal) && !TryParseCustomVideoBitrate(_videoBitrateCustomMbps, out _)) { if (showMessage) { MessageBox.Show( "Поле Custom bitrate, Mbps должно быть числом больше 0.", "Валидация", MessageBoxButton.OK, MessageBoxImage.Warning); } return false; } return true; } private void OnSelectionChanged(object? parameter) { SelectedTracks.Clear(); if (parameter is IList list) { foreach (var r in list.OfType()) { SelectedTracks.Add(r); } } SyncBulkControlsFromSelection(); ContextSetActionCommand.RaiseCanExecuteChanged(); ContextSetBitrateCommand.RaiseCanExecuteChanged(); SetSelectedTracksRemoveCommand.RaiseCanExecuteChanged(); } private bool CanSetSelectedTracksRemove(object? parameter) { if (parameter is not IList list || list.Count == 0) { return false; } return list.OfType().Any(r => r.ValidActions.Contains(TrackActionKind.Remove)); } private void SetSelectedTracksRemove(object? parameter) { if (parameter is not IList list || list.Count == 0) { return; } var rows = list.OfType().ToList(); var changed = false; foreach (var row in rows) { if (!row.ValidActions.Contains(TrackActionKind.Remove)) { continue; } row.Action = TrackActionKind.Remove; changed = true; } if (changed) { ValidateDefaultConflicts(); RecalculatePlanPreview(); SyncBulkControlsFromSelection(); } } private bool CanApplyContextAction(object? parameter) { if (parameter is not TrackActionKind action) { return false; } var rows = SelectedTracks.ToList(); if (rows.Count == 0) { return false; } return rows.Any(r => CanApplyActionToRow(r, action)); } private void ApplyContextAction(object? parameter) { if (parameter is not TrackActionKind action) { return; } var rows = SelectedTracks.ToList(); var changed = false; foreach (var row in rows) { if (!CanApplyActionToRow(row, action)) { continue; } row.Action = action; changed = true; } if (changed) { ValidateDefaultConflicts(); RecalculatePlanPreview(); SyncBulkControlsFromSelection(); } } private bool CanApplyContextBitrate(object? parameter) { if (parameter is not string br || string.IsNullOrWhiteSpace(br)) { return false; } return SelectedTracks.Any(r => r.DataModel.StreamKind == MediaStreamKind.Audio); } private void ApplyContextBitrate(object? parameter) { if (parameter is not string br || string.IsNullOrWhiteSpace(br)) { return; } var rows = SelectedTracks.ToList(); var changed = false; foreach (var row in rows.Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio)) { row.AudioBitrateKbps = br; changed = true; } if (changed) { ValidateDefaultConflicts(); RecalculatePlanPreview(); SyncBulkControlsFromSelection(); } } private static bool CanApplyActionToRow(TrackSettingsRowViewModel row, TrackActionKind action) { if (action == TrackActionKind.Add && row.DataModel.Source != SourceKind.External) { return false; } if (action == TrackActionKind.Convert && row.DataModel.StreamKind is MediaStreamKind.Subtitle or MediaStreamKind.Attachment) { return false; } return row.ValidActions.Contains(action); } private void ApplyBulkActionOnChange(TrackActionKind action) { var rows = GetBulkTargetRows().ToList(); if (rows.Count == 0) { return; } foreach (var row in rows) { if (row.ValidActions.Contains(action)) { row.Action = action; } } ValidateDefaultConflicts(); RecalculatePlanPreview(); SyncBulkControlsFromSelection(); } private void ApplyBulkBitrateOnChange(string bitrate) { var changed = false; foreach (var row in GetBulkTargetRows().Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio)) { row.AudioBitrateKbps = bitrate; changed = true; } if (changed) { ValidateDefaultConflicts(); RecalculatePlanPreview(); SyncBulkControlsFromSelection(); } } private IEnumerable GetBulkTargetRows() => SelectedTracks.Where(IsTrackTypeMatch); private bool IsTrackTypeMatch(TrackSettingsRowViewModel row) => BulkTrackType switch { "Видео" => row.DataModel.StreamKind == MediaStreamKind.Video, "Аудио" => row.DataModel.StreamKind == MediaStreamKind.Audio, "Субтитры" => row.DataModel.StreamKind == MediaStreamKind.Subtitle, "Attachments" => row.DataModel.StreamKind == MediaStreamKind.Attachment, _ => true }; private void SyncBulkControlsFromSelection() { _isSyncingBulkFromSelection = true; try { var rows = GetBulkTargetRows().ToList(); if (rows.Count == 0) { BulkActionValue = null; BulkBitrateValue = null; IsBulkBitrateEnabled = false; return; } var firstAction = rows[0].Action; BulkActionValue = rows.All(r => r.Action == firstAction) ? firstAction : null; var audioRows = rows.Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio).ToList(); IsBulkBitrateEnabled = audioRows.Count > 0; if (audioRows.Count == 0) { BulkBitrateValue = null; return; } var firstBr = audioRows[0].AudioBitrateKbps; BulkBitrateValue = audioRows.All(r => string.Equals(r.AudioBitrateKbps, firstBr, StringComparison.Ordinal)) ? firstBr : null; } finally { _isSyncingBulkFromSelection = false; } ContextSetActionCommand.RaiseCanExecuteChanged(); ContextSetBitrateCommand.RaiseCanExecuteChanged(); } private bool IsDraftDifferentFromAutoPlan() { if (_item.MediaAnalysis is null) { return false; } var profile = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback; var auto = new ConversionTaskOverride(); TrackOverrideSeeder.EnsureDefaults( auto, _item.MediaAnalysis, _item.Sidecars, profile, externalAudio: _item.ExternalAudioFiles, videoPath: _item.FullPath); return !OverridesEquivalent(_draft, auto); } private static bool OverridesEquivalent(ConversionTaskOverride left, ConversionTaskOverride right) { if (!StringEq(left.TargetContainer, right.TargetContainer) || !StringEq(left.TargetVideo, right.TargetVideo) || !StringEq(left.TargetPixelFormat, right.TargetPixelFormat) || !StringEq(left.TargetResolution, right.TargetResolution) || !StringEq(left.TargetFps, right.TargetFps) || !StringEq(left.TargetAudioBitrate, right.TargetAudioBitrate) || !StringEq(left.TargetVideoBitrateMode, right.TargetVideoBitrateMode) || left.TargetVideoBitrateMbps != right.TargetVideoBitrateMbps) { return false; } var l = left.TrackOverrides.OrderBy(TrackKey).ToArray(); var r = right.TrackOverrides.OrderBy(TrackKey).ToArray(); if (l.Length != r.Length) { return false; } for (var i = 0; i < l.Length; i++) { if (!TrackEquivalent(l[i], r[i])) { return false; } } return true; } private static bool TrackEquivalent(TrackOverrideEntry a, TrackOverrideEntry b) { return a.StreamIndex == b.StreamIndex && a.Source == b.Source && a.StreamKind == b.StreamKind && a.Action == b.Action && a.Default == b.Default && StringEq(a.ExternalPath, b.ExternalPath) && a.ExternalAudioStreamOrdinal == b.ExternalAudioStreamOrdinal && StringEq(a.ExternalStreamCodec, b.ExternalStreamCodec) && StringEq(a.ExternalFfprobeTitle, b.ExternalFfprobeTitle) && StringEq(a.Language, b.Language) && StringEq(a.Title, b.Title) && StringEq(a.AudioBitrateKbps, b.AudioBitrateKbps); } private static string TrackKey(TrackOverrideEntry t) => $"{(int)t.Source}|{(int)t.StreamKind}|{t.StreamIndex}|{(t.ExternalPath ?? string.Empty).Trim()}|a{t.ExternalAudioStreamOrdinal}"; private static bool StringEq(string? a, string? b) => string.Equals((a ?? string.Empty).Trim(), (b ?? string.Empty).Trim(), StringComparison.Ordinal); private static bool TryParseCustomVideoBitrate(string? raw, out double mbps) { mbps = 0; if (string.IsNullOrWhiteSpace(raw)) { return false; } return double.TryParse(raw.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out mbps) && mbps > 0; } private bool CanExecutePlayFile(object? parameter) { if (!TryResolvePlayFullPath(parameter, out var path)) { return false; } return File.Exists(path); } private void ExecutePlayFile(object? parameter) { if (!TryResolvePlayFullPath(parameter, out var path)) { return; } if (!File.Exists(path)) { return; } try { Process.Start( new ProcessStartInfo { FileName = path, UseShellExecute = true, }); } catch (Exception ex) { _logging.Error($"Не удалось воспроизвести файл «{path}»: {ex.Message}", "conversion.fileSettings", ex); } } private bool TryResolvePlayFullPath(object? parameter, out string path) { path = string.Empty; var raw = parameter as string; if (string.IsNullOrWhiteSpace(raw)) { raw = FilePath; } if (string.IsNullOrWhiteSpace(raw)) { return false; } raw = raw.Trim(); try { path = Path.GetFullPath(raw); return true; } catch { return false; } } private static string NormalizeContainerForUi(string? rawFormatName, string filePath) { if (string.IsNullOrWhiteSpace(rawFormatName)) { return string.Empty; } var raw = rawFormatName.Trim(); var ext = Path.GetExtension(filePath).Trim().ToLowerInvariant(); if (raw.Contains("matroska", StringComparison.OrdinalIgnoreCase) || raw.Contains("webm", StringComparison.OrdinalIgnoreCase)) { return ext switch { ".webm" => "WebM", ".mkv" => "MKV", _ => "MKV/WebM" }; } if (raw.Contains("mp4", StringComparison.OrdinalIgnoreCase) || raw.Contains("mov", StringComparison.OrdinalIgnoreCase)) { return ext switch { ".mov" => "MOV", ".mp4" or ".m4v" => "MP4", _ => "MP4/MOV" }; } if (raw.Contains("avi", StringComparison.OrdinalIgnoreCase)) { return "AVI"; } if (raw.Contains("mpegts", StringComparison.OrdinalIgnoreCase)) { return "TS"; } if (raw.Contains("mpeg", StringComparison.OrdinalIgnoreCase)) { return "MPEG"; } return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? raw; } }