using System.Collections; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Data; using System.Windows.Input; using System.Windows.Threading; using EmbyToolbox.Models; using EmbyToolbox.Services; using EmbyToolbox.Views; using Microsoft.Win32; namespace EmbyToolbox.ViewModels; public sealed class ConversionViewModel : INotifyPropertyChanged { private readonly LoggingService _logging; private readonly FileDiscoveryService _discoveryService; private readonly QueueAnalysisService _queueAnalysis; private readonly ConversionPlanService _planService; private readonly IProfileSettingsProvider _profile; private readonly TrackSettingsSnapshotService _trackSnapshotService; private readonly ConversionExecutionService _execution; private readonly Func _tempDirectoryProvider; private readonly RecentPathService _recentPaths; private readonly NotificationService _notifications; private readonly Func> _profilesSnapshotForSetup; private readonly Func> _presetRowsForSetup; private readonly Action>? _applyProfilesFromSetupDocument; private readonly BulkTrackSettingsService _bulkTrackSettingsService = new(); private readonly SemaphoreSlim _batchGate = new(1, 1); private readonly HashSet _queuedPaths = new(StringComparer.OrdinalIgnoreCase); private readonly List _selectedQueueItems = []; private CancellationTokenSource? _analysisCts; private CancellationTokenSource? _execCts; private string _defaultQueueProfile = "Emby"; private bool _copyPreviousTrackSettings; private bool _disableSubtitleDefault; private ConversionProfilePresetRow? _selectedDefaultProfile; private bool _isQueueDropHighlight; private bool _isExecutionRunning; private ConversionQueueItem? _selectedQueueItem; private int _overallProgressPercent; private int _completedCount; private int _totalCount; private int _overallQueueTotal; private int _overallQueueDoneCount; private int _overallQueueErrorCount; private string? _currentRunId; private HashSet _currentRunItems = new(); private string _executionPhaseCaption = string.Empty; private bool _copyQueueItemErrorMenuVisible; private string _toastMessage = string.Empty; private bool _isToastVisible; private ToastKind _toastKind; private DispatcherTimer? _toastHideTimer; public ObservableCollection QueueTasks { get; } = new(); public ICollectionView QueueTasksView { get; } public AnalysisProgressViewModel AnalysisProgress { get; } public ConversionFormOptions FormOptions { get; } = new(); public ConversionViewModel( LoggingService logging, FileDiscoveryService discoveryService, QueueAnalysisService queueAnalysis, ConversionPlanService planService, IProfileSettingsProvider profile, TrackSettingsSnapshotService trackSnapshotService, ConversionExecutionService execution, Func tempDirectoryProvider, RecentPathService recentPaths, NotificationService notifications, Func> profilesSnapshotForSetup, Func> presetRowsForSetup, Action>? applyProfilesFromSetupDocument) { _logging = logging; _discoveryService = discoveryService; _queueAnalysis = queueAnalysis; _planService = planService; _profile = profile; _trackSnapshotService = trackSnapshotService; _execution = execution; _tempDirectoryProvider = tempDirectoryProvider; _recentPaths = recentPaths; _notifications = notifications; _profilesSnapshotForSetup = profilesSnapshotForSetup; _presetRowsForSetup = presetRowsForSetup; _applyProfilesFromSetupDocument = applyProfilesFromSetupDocument; AnalysisProgress = new AnalysisProgressViewModel(() => _analysisCts?.Cancel()); QueueTasks.CollectionChanged += OnQueueCollectionChanged; QueueTasksView = CollectionViewSource.GetDefaultView(QueueTasks); AddFilesCommand = new RelayCommand(ExecuteAddFiles, () => !IsExecutionRunning); AddFolderCommand = new RelayCommand(ExecuteAddFolder, () => !IsExecutionRunning); RemoveSelectedFromQueueCommand = new RelayCommand(RemoveSelectedFromQueue, _ => !IsExecutionRunning); RemoveSelectedQueueItemsCommand = new RelayCommand(RemoveSelectedQueueItems, CanRemoveSelectedQueueItems); ShowInFolderCommand = new RelayCommand(ExecuteShowInFolder, CanShowInFolder); PlayFileCommand = new RelayCommand(ExecutePlayFile, CanPlayFile); ClearQueueCommand = new RelayCommand(ExecuteClearQueue, () => QueueTasks.Count > 0 && !IsExecutionRunning); ClearCompletedFromQueueCommand = new RelayCommand(ExecuteClearCompletedFromQueue, CanClearCompletedFromQueue); OpenFileConversionSettingsCommand = new RelayCommand( p => { _ = ExecuteOpenFileSettingsAsync(p); }, CanExecuteOpenFileSettings); OpenTrackSettingsCommand = new RelayCommand(ExecuteOpenTrackSettingsFromKeyboard, CanOpenTrackSettingsFromKeyboard); OpenBulkFileConversionSettingsCommand = new RelayCommand( p => { _ = ExecuteOpenBulkFileSettingsAsync(p); }, CanOpenBulkFileSettings); StartProcessingCommand = new RelayCommand(ExecuteStartProcessing, () => !IsExecutionRunning); StopProcessingCommand = new RelayCommand(ExecuteStopProcessing, () => IsExecutionRunning); CopyQueueItemErrorCommand = new RelayCommand(ExecuteCopyQueueItemError, CanCopyQueueItemError); SaveQueueCommand = new RelayCommand(ExecuteSaveQueue, () => !IsExecutionRunning); LoadQueueCommand = new RelayCommand(ExecuteLoadQueue, () => !IsExecutionRunning); CloseToastCommand = new RelayCommand(HideToastInstant); } public RelayCommand AddFilesCommand { get; } public RelayCommand AddFolderCommand { get; } public RelayCommand RemoveSelectedFromQueueCommand { get; } public RelayCommand RemoveSelectedQueueItemsCommand { get; } public RelayCommand ShowInFolderCommand { get; } public RelayCommand PlayFileCommand { get; } public RelayCommand ClearQueueCommand { get; } public RelayCommand ClearCompletedFromQueueCommand { get; } public RelayCommand OpenFileConversionSettingsCommand { get; } public RelayCommand OpenTrackSettingsCommand { get; } public RelayCommand OpenBulkFileConversionSettingsCommand { get; } public RelayCommand StartProcessingCommand { get; } public RelayCommand StopProcessingCommand { get; } public RelayCommand CopyQueueItemErrorCommand { get; } public RelayCommand SaveQueueCommand { get; } public RelayCommand LoadQueueCommand { get; } public RelayCommand CloseToastCommand { get; } 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(); } } public bool CopyQueueItemErrorMenuVisible { get => _copyQueueItemErrorMenuVisible; private set { if (_copyQueueItemErrorMenuVisible == value) { return; } _copyQueueItemErrorMenuVisible = value; OnPropertyChanged(); } } public void RefreshCopyQueueItemErrorMenuState(IList? selected) { _selectedQueueItems.Clear(); if (selected is not null) { _selectedQueueItems.AddRange(selected.OfType()); } CopyQueueItemErrorMenuVisible = ConversionQueueItemErrorCopy.ShouldShowForSelection(selected); CopyQueueItemErrorCommand.RaiseCanExecuteChanged(); OpenTrackSettingsCommand.RaiseCanExecuteChanged(); OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); } /// Краткая строка для единого прогресса (например «Конвертация файла 3 из 12...»). public string ExecutionPhaseCaption { get => _executionPhaseCaption; private set { if (_executionPhaseCaption == value) { return; } _executionPhaseCaption = value; OnPropertyChanged(); } } /// Отображаемый общий прогресс (Floor от средних DisplayProgressPercent; без 100%, пока есть активные задачи). public int OverallProgressPercent { get => _overallProgressPercent; private set { if (_overallProgressPercent == value) { return; } _overallProgressPercent = value; OnPropertyChanged(); OnPropertyChanged(nameof(OverallProgressPercentLabel)); } } public int CompletedCount { get => _completedCount; private set { if (_completedCount == value) { return; } _completedCount = value; OnPropertyChanged(); } } public int TotalCount { get => _totalCount; private set { if (_totalCount == value) { return; } _totalCount = value; OnPropertyChanged(); } } /// Все задачи в очереди (для сводки рядом с прогрессом). public int OverallQueueTotal { get => _overallQueueTotal; private set { if (_overallQueueTotal == value) { return; } _overallQueueTotal = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasQueueTasks)); } } /// Завершённые со статусом «Готово». public int OverallQueueDoneCount { get => _overallQueueDoneCount; private set { if (_overallQueueDoneCount == value) { return; } _overallQueueDoneCount = value; OnPropertyChanged(); } } /// Задачи со статусом «Ошибка». public int OverallQueueErrorCount { get => _overallQueueErrorCount; private set { if (_overallQueueErrorCount == value) { return; } _overallQueueErrorCount = value; OnPropertyChanged(); } } public bool HasQueueTasks => OverallQueueTotal > 0; public string OverallProgressPercentLabel => $"{OverallProgressPercent}%"; public string? CurrentRunId => _currentRunId; public bool IsExecutionRunning { get => _isExecutionRunning; private set { if (_isExecutionRunning == value) { return; } _isExecutionRunning = value; OnPropertyChanged(); OnPropertyChanged(nameof(CanEditQueue)); AddFilesCommand.RaiseCanExecuteChanged(); AddFolderCommand.RaiseCanExecuteChanged(); RemoveSelectedFromQueueCommand.RaiseCanExecuteChanged(); RemoveSelectedQueueItemsCommand.RaiseCanExecuteChanged(); ShowInFolderCommand.RaiseCanExecuteChanged(); PlayFileCommand.RaiseCanExecuteChanged(); CopyQueueItemErrorCommand.RaiseCanExecuteChanged(); ClearQueueCommand.RaiseCanExecuteChanged(); OpenFileConversionSettingsCommand.RaiseCanExecuteChanged(); OpenTrackSettingsCommand.RaiseCanExecuteChanged(); OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); StartProcessingCommand.RaiseCanExecuteChanged(); StopProcessingCommand.RaiseCanExecuteChanged(); SaveQueueCommand.RaiseCanExecuteChanged(); LoadQueueCommand.RaiseCanExecuteChanged(); ClearCompletedFromQueueCommand.RaiseCanExecuteChanged(); } } public bool CanEditQueue => !IsExecutionRunning; public ConversionQueueItem? SelectedQueueItem { get => _selectedQueueItem; set { if (ReferenceEquals(_selectedQueueItem, value)) { return; } _selectedQueueItem = value; OnPropertyChanged(); ShowInFolderCommand.RaiseCanExecuteChanged(); PlayFileCommand.RaiseCanExecuteChanged(); CopyQueueItemErrorCommand.RaiseCanExecuteChanged(); OpenTrackSettingsCommand.RaiseCanExecuteChanged(); RemoveSelectedQueueItemsCommand.RaiseCanExecuteChanged(); } } public ConversionProfilePresetRow? SelectedDefaultProfile { get => _selectedDefaultProfile; set { if (ReferenceEquals(_selectedDefaultProfile, value)) { return; } _selectedDefaultProfile = value; if (value is not null) { _defaultQueueProfile = value.Profile; } OnPropertyChanged(); OnPropertyChanged(nameof(DefaultQueueProfile)); } } public string DefaultQueueProfile => _defaultQueueProfile; /// Автоматически применять настройки дорожек из snapshot предыдущего настроенного файла (если структура совпадает). public bool CopyPreviousTrackSettings { get => _copyPreviousTrackSettings; set { if (_copyPreviousTrackSettings == value) { return; } _copyPreviousTrackSettings = value; OnPropertyChanged(); } } public bool DisableSubtitleDefault { get => _disableSubtitleDefault; set { if (_disableSubtitleDefault == value) { return; } _disableSubtitleDefault = value; OnPropertyChanged(); ReapplySubtitleDefaultRuleToAnalyzedTasks(); } } public void SyncDefaultProfileFromList(IReadOnlyList profiles) { if (profiles is null || profiles.Count == 0) { if (_selectedDefaultProfile is not null) { _selectedDefaultProfile = null; OnPropertyChanged(nameof(SelectedDefaultProfile)); } return; } var match = profiles.FirstOrDefault(p => p.Profile.Equals(_defaultQueueProfile, StringComparison.OrdinalIgnoreCase)) ?? profiles.FirstOrDefault(p => p.Profile.Equals("Emby", StringComparison.OrdinalIgnoreCase)) ?? profiles[0]; if (ReferenceEquals(_selectedDefaultProfile, match) && string.Equals(_defaultQueueProfile, match.Profile, StringComparison.Ordinal)) { return; } _selectedDefaultProfile = match; _defaultQueueProfile = match.Profile; OnPropertyChanged(nameof(SelectedDefaultProfile)); OnPropertyChanged(nameof(DefaultQueueProfile)); } public bool IsQueueDropHighlight { get => _isQueueDropHighlight; set { if (_isQueueDropHighlight == value) { return; } _isQueueDropHighlight = value; OnPropertyChanged(); } } public void ProcessPathsDroppedOnQueue(string[]? paths) { if (paths is null || paths.Length == 0) { return; } var addOptions = ShowAddFilesOptionsDialog(); if (addOptions is null) { return; } EnqueueFromFileSystemPathArray(paths, "перетаскивание", addOptions, snapshotScopeExplicitRoot: null); } public event PropertyChangedEventHandler? PropertyChanged; private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } private void OnQueueCollectionChanged(object? s, NotifyCollectionChangedEventArgs e) { if (e.NewItems is not null) { foreach (ConversionQueueItem item in e.NewItems) { item.PropertyChanged += OnQueueItemPropertyChanged; } } if (e.OldItems is not null) { foreach (ConversionQueueItem item in e.OldItems) { item.PropertyChanged -= OnQueueItemPropertyChanged; } } ShowInFolderCommand.RaiseCanExecuteChanged(); PlayFileCommand.RaiseCanExecuteChanged(); TouchClearCommand(); OpenTrackSettingsCommand.RaiseCanExecuteChanged(); OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); RecalculateOverallProgress(); } private void OnQueueItemPropertyChanged(object? s, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ConversionQueueItem.Profile) && s is ConversionQueueItem item) { OnTaskProfileChanged(item); } if (e.PropertyName is nameof(ConversionQueueItem.Progress) or nameof(ConversionQueueItem.Status) or nameof(ConversionQueueItem.FullPath)) { ShowInFolderCommand.RaiseCanExecuteChanged(); PlayFileCommand.RaiseCanExecuteChanged(); ClearCompletedFromQueueCommand.RaiseCanExecuteChanged(); OpenTrackSettingsCommand.RaiseCanExecuteChanged(); OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged(); RecalculateOverallProgress(); } } private void OnTaskProfileChanged(ConversionQueueItem item) { if (item.MediaAnalysis is null) { return; } if (IsExecutionRunning) { return; } if (item.Status is ConversionQueueStatus.Analyzing or ConversionQueueStatus.Error or ConversionQueueStatus.Cancelled) { return; } var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; TrackOverrideSeeder.SyncTargetFieldsFromProfile(item.TaskOverride, prof); var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles); item.SetPlan(plan); if (item.Status is ConversionQueueStatus.Done or ConversionQueueStatus.Error or ConversionQueueStatus.Cancelled) { ResetTaskForReprocessing(item); _logging.Info($"Задача возвращена в очередь после изменения настроек: {item.FullPath}", "conversion.queue"); } } public void RecalculateAllAnalyzedForProfileUpdate() { foreach (var item in QueueTasks) { if (item.MediaAnalysis is not null && (string.Equals(item.Status, ConversionQueueStatus.Pending, StringComparison.Ordinal) || string.Equals(item.Status, ConversionQueueStatus.Ready, StringComparison.Ordinal))) { var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; // Keep per-file manual overrides intact; only untouched tasks follow updated profile targets. if (!item.IsManuallyEdited) { TrackOverrideSeeder.SyncTargetFieldsFromProfile(item.TaskOverride, prof); } if (DisableSubtitleDefault) { foreach (var subtitle in item.TaskOverride.TrackOverrides.Where(t => t.StreamKind == MediaStreamKind.Subtitle)) { subtitle.Default = false; } } var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles); item.SetPlan(plan); } } } private bool CanExecuteOpenFileSettings(object? p) => !IsExecutionRunning && p is ConversionQueueItem i && i.Status is not ( ConversionQueueStatus.Analyzing or ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing) && !string.IsNullOrWhiteSpace(i.FullPath) && File.Exists(i.FullPath); private async Task ExecuteOpenFileSettingsAsync(object? parameter) { try { if (IsExecutionRunning || parameter is not ConversionQueueItem item) { return; } var needsProbe = item.MediaAnalysis is null || string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); if (needsProbe) { DoneCompletionState? preserve = null; if (string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)) { preserve = new DoneCompletionState( item.Progress, item.LastRunId, item.IsProcessed, item.ProcessedInCurrentRun); } var ok = await _queueAnalysis.RefreshItemMediaForTrackEditorAsync( item, autoRemoveForeignTracks: false, DisableSubtitleDefault, preserve, RunOnUiActionAsync, CancellationToken.None) .ConfigureAwait(true); if (!ok) { ShowToast("Не удалось прочитать дорожки файла (ffprobe).", ToastKind.Warning); return; } } if (item.MediaAnalysis is null) { return; } var w = new FileConversionSettingsWindow { Owner = Application.Current?.MainWindow }; w.DataContext = new FileConversionSettingsViewModel( item, _planService, _profile, _trackSnapshotService, _logging, FormOptions, CopyPreviousTrackSettings, OnFileSettingsSaved, () => w.Close()); w.ShowDialog(); } catch (Exception ex) { _logging.Error($"форма дорожек: {ex.Message}", "conversion.ui", ex); } } /// /// Перед формой дорожек: ffprobe для «Готово» и строк без MediaAnalysis. /// private async Task EnsureMediaFreshForTrackEditorsAsync( IReadOnlyList items, CancellationToken cancellationToken = default) { foreach (var item in items) { var needsProbe = item.MediaAnalysis is null || string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); if (!needsProbe) { continue; } if (string.IsNullOrWhiteSpace(item.FullPath) || !File.Exists(item.FullPath)) { return false; } DoneCompletionState? preserve = null; if (string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)) { preserve = new DoneCompletionState( item.Progress, item.LastRunId, item.IsProcessed, item.ProcessedInCurrentRun); } var ok = await _queueAnalysis.RefreshItemMediaForTrackEditorAsync( item, autoRemoveForeignTracks: false, DisableSubtitleDefault, preserve, RunOnUiActionAsync, cancellationToken) .ConfigureAwait(true); if (!ok) { return false; } } return true; } private bool CanOpenTrackSettingsFromKeyboard(object? parameter) { if (IsExecutionRunning) { return false; } var item = ResolveItemForTrackSettings(parameter); return item is not null && item.Status is not ( ConversionQueueStatus.Analyzing or ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing) && !string.IsNullOrWhiteSpace(item.FullPath) && File.Exists(item.FullPath); } private void ExecuteOpenTrackSettingsFromKeyboard(object? parameter) { var item = ResolveItemForTrackSettings(parameter); if (!CanOpenTrackSettingsFromKeyboard(parameter) || item is null) { return; } _logging.Debug("Открыты настройки дорожек через F2", "conversion.keyboard"); _ = ExecuteOpenFileSettingsAsync(item); } private ConversionQueueItem? ResolveItemForTrackSettings(object? parameter) { if (parameter is IList list && list.Count > 0) { if (_selectedQueueItem is not null && list.Contains(_selectedQueueItem)) { return _selectedQueueItem; } return list[list.Count - 1] as ConversionQueueItem; } return _selectedQueueItem; } private bool CanOpenBulkFileSettings(object? parameter) { if (IsExecutionRunning) { return false; } var selected = ResolveBulkSelection(parameter); if (selected.Count < 2) { return false; } return selected.All(i => i.Status is not ( ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing)); } private async Task ExecuteOpenBulkFileSettingsAsync(object? parameter) { var selected = ResolveBulkSelection(parameter); if (selected.Count < 2) { return; } if (!await EnsureMediaFreshForTrackEditorsAsync(selected, CancellationToken.None).ConfigureAwait(true)) { ShowToast("Не удалось обновить сведения о файлах (ffprobe).", ToastKind.Warning); return; } _logging.Info($"массовая настройка: выбрано строк {selected.Count}", "conversion.bulk"); var analysis = _bulkTrackSettingsService.Analyze(selected); if (!analysis.HasMajority || analysis.MajorityItems.Count < 2) { ShowToast("Невозможно выполнить массовую настройку: структуры дорожек слишком различаются.", ToastKind.Warning); _logging.Warning("массовая настройка: большинство не определено", "conversion.bulk"); return; } var skippedRows = analysis.SkippedItems.Select(i => i.OrderNumber).OrderBy(i => i).ToList(); _logging.Info( $"массовая настройка: основной структуры {analysis.MajorityItems.Count}, отличаются: {FormatRowList(skippedRows)}", "conversion.bulk"); if (skippedRows.Count > 0) { ShowToast($"Строки № {FormatRowList(skippedRows)} отличаются от большинства и не будут изменены.", ToastKind.Warning); } var representative = analysis.MajorityItems.OrderBy(i => i.OrderNumber).First(); var dialog = new BulkFileConversionSettingsWindow { Owner = Application.Current?.MainWindow }; dialog.DataContext = new BulkFileConversionSettingsViewModel( representative, analysis.MajorityItems, FormOptions, editedTemplateTracks => ApplyBulkEdits(analysis, editedTemplateTracks), () => dialog.Close()); dialog.ShowDialog(); } private void ApplyBulkEdits(BulkTrackSelectionAnalysis analysis, IReadOnlyList editedTemplateTracks) { _bulkTrackSettingsService.ApplyBulkEdits(analysis.MajorityItems, editedTemplateTracks); var affected = 0; foreach (var item in analysis.MajorityItems) { var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; var plan = _planService.Build(item.MediaAnalysis!, item.Sidecars, prof, item.TaskOverride, item.ExternalAudioFiles); item.IsManuallyEdited = true; item.SetPlan(plan); if (item.Status is ConversionQueueStatus.Done or ConversionQueueStatus.Error or ConversionQueueStatus.Cancelled) { ResetTaskForReprocessing(item); } else { item.Progress = 0; } affected++; } var skippedRows = analysis.SkippedItems.Select(i => i.OrderNumber).OrderBy(i => i).ToList(); var message = skippedRows.Count == 0 ? $"Массовые настройки применены к {affected} файлам" : $"Массовые настройки применены к {affected} файлам. Пропущены строки: {FormatRowList(skippedRows)}"; _logging.Info(message, "conversion.bulk"); ShowToast(message, ToastKind.Success); } private List ResolveBulkSelection(object? parameter) { if (parameter is IList list) { return list.OfType().Distinct().ToList(); } return _selectedQueueItems.Distinct().ToList(); } private static string FormatRowList(IReadOnlyList rows) => rows.Count == 0 ? "—" : string.Join(", ", rows); private void ShowToast(string message, ToastKind kind) { if (string.IsNullOrWhiteSpace(message)) { return; } _toastHideTimer?.Stop(); _toastHideTimer = null; ToastMessage = message.Trim(); ToastKind = kind; IsToastVisible = true; var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; _toastHideTimer = new DispatcherTimer( TimeSpan.FromSeconds(3), DispatcherPriority.Background, (_, _) => HideToastInstant(), dispatcher); } private void HideToastInstant() { _toastHideTimer?.Stop(); _toastHideTimer = null; IsToastVisible = false; ToastMessage = string.Empty; } private void OnFileSettingsSaved(ConversionQueueItem item, bool hasChangesFromCurrent) { if (!hasChangesFromCurrent) { return; } if (item.Status is ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing) { return; } ResetTaskForReprocessing(item); _logging.Info($"Задача возвращена в очередь после изменения настроек: {item.FullPath}", "conversion.queue"); } private void TouchClearCommand() { ClearQueueCommand.RaiseCanExecuteChanged(); ClearCompletedFromQueueCommand.RaiseCanExecuteChanged(); } /// Удаляются только задачи в статусе «Готово» (Done). Ошибки и отменённые остаются в очереди. private static bool IsClearedWhenRemovingCompletedTasks(string status) => string.Equals(status, ConversionQueueStatus.Done, StringComparison.Ordinal); private bool CanClearCompletedFromQueue() => !IsExecutionRunning && QueueTasks.Any(t => IsClearedWhenRemovingCompletedTasks(t.Status)); private void ExecuteClearCompletedFromQueue() { if (!CanClearCompletedFromQueue()) { return; } var removed = 0; for (var i = QueueTasks.Count - 1; i >= 0; i--) { var item = QueueTasks[i]; if (!IsClearedWhenRemovingCompletedTasks(item.Status)) { continue; } _queuedPaths.Remove(item.FullPath); QueueTasks.RemoveAt(i); removed++; } if (removed == 0) { return; } RenumberQueue(); _logging.Info($"очередь: удалено завершённых задач: {removed}, осталось: {QueueTasks.Count}", "conversion.queue"); TouchClearCommand(); RecalculateOverallProgress(); } private async void ExecuteStartProcessing() { if (IsExecutionRunning) { return; } var preparedResetCount = 0; foreach (var item in QueueTasks) { if (string.Equals(item.Status, ConversionQueueStatus.Error, StringComparison.Ordinal) || string.Equals(item.Status, ConversionQueueStatus.Cancelled, StringComparison.Ordinal)) { PrepareErrorOrCancelledTaskForQueuedRetry(item); preparedResetCount++; } } var runItems = QueueTasks.Where(IsEligibleForConversionRunStatus).ToList(); TryAutoSaveQueueBeforeRun(); _logging.Info($"задач для повторного запуска: {runItems.Count} (из «Ошибка»/«Отмена» подготовлено: {preparedResetCount})", "conversion.queue"); if (runItems.Count == 0) { return; } IsExecutionRunning = true; _execCts = new CancellationTokenSource(); var token = _execCts.Token; IReadOnlyList? runSnapshot = null; var countingRunId = string.Empty; var cancelledByUser = false; try { runSnapshot = runItems; var runId = Guid.NewGuid().ToString("N"); countingRunId = runId; _currentRunId = runId; OnPropertyChanged(nameof(CurrentRunId)); _currentRunItems = runItems.ToHashSet(); foreach (var item in QueueTasks) { item.ProcessedInCurrentRun = false; } RecalculateOverallProgress(); await _execution.RunQueueAsync( runItems, name => _profile.GetProfile(name), _tempDirectoryProvider(), runId, RunOnUiActionAsync, token) .ConfigureAwait(false); } catch (OperationCanceledException) { cancelledByUser = true; _logging.Warning("обработка очереди остановлена пользователем", "conversion.exec"); } finally { NotifyConversionQueueEnded(runSnapshot, countingRunId, cancelledByUser); await RunOnUiActionAsync( () => { _currentRunId = null; OnPropertyChanged(nameof(CurrentRunId)); _currentRunItems = new HashSet(); IsExecutionRunning = false; RecalculateOverallProgress(); }) .ConfigureAwait(false); _execCts?.Dispose(); _execCts = null; } } private void NotifyConversionQueueEnded( IReadOnlyList? runSnapshot, string countingRunId, bool cancelledByUser) { try { if (runSnapshot is null || runSnapshot.Count == 0 || string.IsNullOrEmpty(countingRunId)) { return; } if (cancelledByUser) { _notifications.NotifyQueueCancelled(); return; } var successCount = runSnapshot.Count(i => string.Equals(i.LastRunId, countingRunId, StringComparison.OrdinalIgnoreCase) && string.Equals(i.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)); var errorCount = runSnapshot.Count(i => string.Equals(i.LastRunId, countingRunId, StringComparison.OrdinalIgnoreCase) && string.Equals(i.Status, ConversionQueueStatus.Error, StringComparison.Ordinal)); if (successCount == 0 && errorCount == 0) { return; } _notifications.NotifyQueueCompleted(successCount, errorCount); } catch (Exception ex) { _logging.Warning($"завершение уведомлений очереди: {ex.Message}", "notify", ex); } } private void ExecuteStopProcessing() { _execCts?.Cancel(); } private static bool IsEligibleForConversionRunStatus(ConversionQueueItem item) => string.Equals(item.Status, ConversionQueueStatus.Pending, StringComparison.Ordinal) || string.Equals(item.Status, ConversionQueueStatus.Ready, StringComparison.Ordinal); private static void PrepareErrorOrCancelledTaskForQueuedRetry(ConversionQueueItem item) { item.Status = ConversionQueueStatus.Pending; item.Progress = 0; item.ErrorMessage = null; item.ErrorDetails = null; item.IsProcessed = false; item.ProcessedInCurrentRun = false; item.LastRunId = null; } private void TryAutoSaveQueueBeforeRun() { try { var path = ConversionQueueSetupPersistence.AllocateAutoSavePath(); var doc = BuildQueueSetupRoot(); ConversionQueueSetupPersistence.SaveToPath(path, doc); _logging.Info($"очередь автоматически сохранена перед запуском. Файл: {path}", "conversion.queue"); } catch (Exception ex) { _logging.Warning($"автосохранение очереди перед запуском не выполнено: {ex.Message}", "conversion.queue", ex); } } private ConversionQueueSetupRoot BuildQueueSetupRoot() { var form = new ConversionFormOptionsSnapshot { ContainerOptions = FormOptions.ContainerOptions.ToList(), VideoCodecOptions = FormOptions.VideoCodecOptions.ToList(), PixelFormatOptions = FormOptions.PixelFormatOptions.ToList(), ResolutionOptions = FormOptions.ResolutionOptions.ToList(), FpsOptions = FormOptions.FpsOptions.ToList(), AudioBitrateKbps = FormOptions.AudioBitrateKbps.ToList(), VideoBitrateModeOptions = FormOptions.VideoBitrateModeOptions.ToList(), }; return new ConversionQueueSetupRoot { SchemaVersion = 1, SavedAtUtc = DateTime.UtcNow, DefaultQueueProfile = DefaultQueueProfile, CopyPreviousTrackSettings = CopyPreviousTrackSettings, DisableSubtitleDefault = DisableSubtitleDefault, FormOptions = form, Profiles = _profilesSnapshotForSetup(), Tasks = QueueTasks.Select(ToPersistTaskModel).ToList(), }; } private static ConversionQueueTaskPersistModel ToPersistTaskModel(ConversionQueueItem item) { return new ConversionQueueTaskPersistModel { FullPath = item.FullPath, SnapshotScopeBatchRoot = item.SnapshotScopeBatchRoot, OrderNumber = item.OrderNumber, Profile = item.Profile, PlanSummary = item.PlanSummary, Status = item.Status, Progress = item.Progress, IsManuallyEdited = item.IsManuallyEdited, IsProcessed = item.IsProcessed, ProcessedInCurrentRun = item.ProcessedInCurrentRun, LastRunId = item.LastRunId, ErrorMessage = item.ErrorMessage, ErrorDetails = item.ErrorDetails, FileSizeMb = item.FileSizeMb, HasFfprobeAudioSummary = item.HasFfprobeAudioSummary, FfprobeAudioCount = item.FfprobeEmbeddedAudioStreamCount, FfprobeAudioSizeMb = item.FfprobeAudioSizeEstimateMb, FfprobeAudioSizePartial = item.FfprobeAudioSizeEstimatePartial, MediaAnalysis = item.MediaAnalysis, Sidecars = item.Sidecars.Select(SidecarFilePersistModel.From).ToList(), ExternalAudioFiles = item.ExternalAudioFiles.Select(ExternalAudioFilePersistModel.From).ToList(), Overrides = ConversionTaskOverridePersistModel.From(item.TaskOverride), }; } private void ExecuteSaveQueue() { if (IsExecutionRunning) { return; } var dialog = new SaveFileDialog { Title = "Сохранить очередь конвертации", Filter = $"Настройка очереди (*{ConversionQueueSetupPersistence.FileExtension})|*{ConversionQueueSetupPersistence.FileExtension}", FileName = $"conversion-setup-{DateTime.Now:yyyy-MM-dd-HH-mm-ss}{ConversionQueueSetupPersistence.FileExtension}", InitialDirectory = ConversionQueueSetupPersistence.GetQueueSetupsDirectory(), }; if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FileName)) { return; } try { var doc = BuildQueueSetupRoot(); ConversionQueueSetupPersistence.SaveToPath(dialog.FileName, doc); _logging.Info($"очередь сохранена в файл: {dialog.FileName}", "conversion.queue"); } catch (Exception ex) { _logging.Error($"сохранение очереди: {ex.Message}", "conversion.queue", ex); } } private void ExecuteLoadQueue() { if (IsExecutionRunning) { return; } var dialog = new OpenFileDialog { Title = "Загрузить очередь конвертации", Filter = $"Настройка очереди (*{ConversionQueueSetupPersistence.FileExtension})|*{ConversionQueueSetupPersistence.FileExtension}|Все файлы|*.*", InitialDirectory = ConversionQueueSetupPersistence.GetQueueSetupsDirectory(), }; if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FileName)) { return; } ConversionQueueSetupRoot doc; try { doc = ConversionQueueSetupPersistence.LoadFromPath(dialog.FileName); } catch (Exception ex) { _logging.Error($"загрузка .conv_setup: {ex.Message}", "conversion.queue", ex); return; } if (doc.Profiles is { Count: > 0 }) { _applyProfilesFromSetupDocument?.Invoke(doc.Profiles); } CopyPreviousTrackSettings = doc.CopyPreviousTrackSettings; DisableSubtitleDefault = doc.DisableSubtitleDefault; var opts = doc.FormOptions ?? new ConversionFormOptionsSnapshot(); FormOptions.RestoreListsFromSerialized( opts.ContainerOptions, opts.VideoCodecOptions, opts.PixelFormatOptions, opts.ResolutionOptions, opts.FpsOptions, opts.AudioBitrateKbps, opts.VideoBitrateModeOptions); if (!string.IsNullOrWhiteSpace(doc.DefaultQueueProfile)) { _defaultQueueProfile = doc.DefaultQueueProfile.Trim(); OnPropertyChanged(nameof(DefaultQueueProfile)); } SyncDefaultProfileFromList(_presetRowsForSetup()); _analysisCts?.Cancel(); QueueTasks.Clear(); _queuedPaths.Clear(); var restored = 0; foreach (var t in doc.Tasks ?? []) { restored++; string full; try { full = Path.GetFullPath(t.FullPath); } catch { full = t.FullPath; } var item = new ConversionQueueItem(full); item.SnapshotScopeBatchRoot = t.SnapshotScopeBatchRoot; item.OrderNumber = t.OrderNumber; item.Profile = string.IsNullOrWhiteSpace(t.Profile) ? "Emby" : t.Profile; item.IsManuallyEdited = t.IsManuallyEdited; if (t.Overrides is not null) { t.Overrides.ApplyTo(item.TaskOverride); } if (!File.Exists(item.FullPath)) { item.Status = ConversionQueueStatus.Error; item.Progress = 0; item.ErrorMessage = "Файл не найден"; item.ErrorDetails = null; item.PlanSummary = string.IsNullOrWhiteSpace(t.PlanSummary) ? "—" : t.PlanSummary; } else { item.RefreshFileSizeFromDisk(); if (t.MediaAnalysis is null) { item.Status = ConversionQueueStatus.Error; item.Progress = 0; item.ErrorMessage = "Нет сохранённых данных анализа."; item.ErrorDetails = null; item.PlanSummary = string.IsNullOrWhiteSpace(t.PlanSummary) ? "—" : t.PlanSummary; } else { var sidecars = (t.Sidecars ?? []).Select(s => s.ToModel()).ToList(); var ext = (t.ExternalAudioFiles ?? []).Select(e => e.ToModel()).ToList(); item.RestorePersistedMediaSnapshot( t.MediaAnalysis, sidecars, ext, t.HasFfprobeAudioSummary, t.FfprobeAudioCount, t.FfprobeAudioSizeMb, t.FfprobeAudioSizePartial); var prof = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; var plan = _planService.Build(item.MediaAnalysis!, sidecars, prof, item.TaskOverride, ext); item.SetPlan(plan); item.Status = NormalizeLoadedExecutionStatus(t.Status); if (string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)) { item.Progress = 100; } else { item.Progress = Math.Clamp(t.Progress, 0, 99); } item.IsProcessed = t.IsProcessed; if (string.Equals(item.Status, ConversionQueueStatus.Error, StringComparison.Ordinal)) { item.ErrorMessage = string.IsNullOrWhiteSpace(t.ErrorMessage) ? "Ошибка" : t.ErrorMessage.Trim(); item.ErrorDetails = t.ErrorDetails; } else { item.ErrorMessage = null; item.ErrorDetails = null; } } } item.ProcessedInCurrentRun = false; item.LastRunId = null; QueueTasks.Add(item); _queuedPaths.Add(item.FullPath); } RenumberQueue(); TouchClearCommand(); RecalculateOverallProgress(); _logging.Info($"очередь загружена из файла: {dialog.FileName}. Восстановлено задач: {restored}", "conversion.queue"); } private static string NormalizeLoadedExecutionStatus(string? saved) { if (string.IsNullOrWhiteSpace(saved)) { return ConversionQueueStatus.Pending; } foreach (var transient in TransientStatusesNotRestoredAfterLoad()) { if (string.Equals(saved, transient, StringComparison.Ordinal)) { return ConversionQueueStatus.Pending; } } return saved; } private static IEnumerable TransientStatusesNotRestoredAfterLoad() { yield return ConversionQueueStatus.Analyzing; yield return ConversionQueueStatus.Running; yield return ConversionQueueStatus.Copying; yield return ConversionQueueStatus.Replacing; } private void ExecuteAddFiles() { if (IsExecutionRunning) { return; } var dialog = new OpenFileDialog { Title = "Выберите видеофайлы", Multiselect = true, Filter = "Видео файлы|*.mkv;*.mp4;*.avi;*.mov;*.wmv;*.flv;*.ts;*.m2ts;*.webm;*.mpeg;*.mpg;*.m4v;*.3gp;*.ogv;*.vob;*.rmvb;*.asf;*.divx;*.f4v;*.mts;*.m2v;*.mp2;*.mpv;*.qt;*.hevc;*.h265;*.h264|Все файлы|*.*", InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.ConversionAddFiles), }; if (dialog.ShowDialog() != true) { return; } _recentPaths.RememberChosenFiles(RecentPathScenario.ConversionAddFiles, dialog.FileNames); var paths = FileDiscoveryService.SortVideoPathsByFullPath( dialog.FileNames.Where(f => _discoveryService.IsSupportedVideoFile(f)).Select(Path.GetFullPath)); var addOptions = ShowAddFilesOptionsDialog(); if (addOptions is null) { return; } EnqueueFromFileSystemPathArray(paths, "добавить файлы", addOptions, snapshotScopeExplicitRoot: null); } private void ExecuteAddFolder() { if (IsExecutionRunning) { return; } var dialog = new OpenFolderDialog { Title = "Выберите каталог с видео", InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.ConversionAddFolder), }; if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName)) { return; } _recentPaths.RememberChosenFolder(RecentPathScenario.ConversionAddFolder, dialog.FolderName); var fullRoot = Path.GetFullPath(dialog.FolderName); var list = _discoveryService.DiscoverVideoFiles(fullRoot, err => _logging.Error(err, "conversion.discovery")); var addOptions = ShowAddFilesOptionsDialog(); if (addOptions is null) { return; } EnqueueVideoFiles(list, "добавить каталог", addOptions, snapshotScopeExplicitRoot: fullRoot); } private void ExecuteClearQueue() { if (IsExecutionRunning) { return; } var n = QueueTasks.Count; if (n == 0) { return; } _analysisCts?.Cancel(); QueueTasks.Clear(); _queuedPaths.Clear(); _logging.Info($"очередь очищена, удалено записей: {n}", "conversion.queue"); TouchClearCommand(); RecalculateOverallProgress(); } private void EnqueueFromFileSystemPathArray( IReadOnlyList pathEntries, string opTag, AddFilesOptions addOptions, string? snapshotScopeExplicitRoot) { var videoPaths = _discoveryService.CollectVideoFilesFromFileSystemEntries(pathEntries, err => _logging.Error(err, "conversion.discovery")); EnqueueVideoFiles(videoPaths, opTag, addOptions, snapshotScopeExplicitRoot); } private void EnqueueVideoFiles( IReadOnlyList videoPaths, string opTag, AddFilesOptions addOptions, string? snapshotScopeExplicitRoot = null) { if (videoPaths.Count == 0) { _logging.Info("очередь: нет поддерживаемых видео для добавления", "conversion.queue"); return; } var profile = CurrentProfileNameForNewTasks(); var added = 0; var dups = 0; var newBatch = new List(); List existingFullForScope = []; foreach (var path in videoPaths) { var full = Path.GetFullPath(path); if (!File.Exists(full)) { continue; } existingFullForScope.Add(full); } var inferredBatchScope = SnapshotScopePaths.TryGetLowestCommonAncestorDirectory(existingFullForScope); string? batchScopeStored = string.IsNullOrWhiteSpace(snapshotScopeExplicitRoot) ? inferredBatchScope : SnapshotScopePaths.NormalizeScopeDirectory(snapshotScopeExplicitRoot); batchScopeStored = string.IsNullOrWhiteSpace(batchScopeStored) ? null : batchScopeStored; foreach (var path in videoPaths) { var full = Path.GetFullPath(path); if (!File.Exists(full)) { continue; } if (!_queuedPaths.Add(full)) { dups++; _logging.Info($"очередь: пропущен дубликат — {full}", "conversion.queue"); continue; } var item = new ConversionQueueItem(full) { SnapshotScopeBatchRoot = batchScopeStored, OrderNumber = QueueTasks.Count + 1, Status = ConversionQueueStatus.Analyzing, Progress = 0, Profile = profile, PlanSummary = "Анализ…" }; QueueTasks.Add(item); newBatch.Add(item); added++; } if (added > 0) { RenumberQueue(); _ = RunAnalysisBatchesChainedAsync(newBatch, addOptions); } TouchClearCommand(); var found = videoPaths.Count; _logging.Info( $"очередь ({opTag}): найдено {found}, добавлено {added}, пропущено дубликатов: {dups}; порядок: сортировка по полному пути ({nameof(StringComparer.OrdinalIgnoreCase)}), всего в очереди: {QueueTasks.Count}", "conversion.queue"); RecalculateOverallProgress(); } private string CurrentProfileNameForNewTasks() => string.IsNullOrWhiteSpace(_defaultQueueProfile) ? "Emby" : _defaultQueueProfile; private void RenumberQueue() { for (var i = 0; i < QueueTasks.Count; i++) { var n = i + 1; if (QueueTasks[i].OrderNumber != n) { QueueTasks[i].OrderNumber = n; } } } private async Task RunAnalysisBatchesChainedAsync(IReadOnlyList batch, AddFilesOptions addOptions) { if (batch.Count == 0) { return; } await _batchGate.WaitAsync().ConfigureAwait(false); _analysisCts = new CancellationTokenSource(); var cts = _analysisCts; var token = cts.Token; var autoRemoveForeignTracksForBatch = addOptions.RemoveForeignAudioAndSubtitles; var disableSubtitleDefaultForBatch = DisableSubtitleDefault; var app = Application.Current; try { if (app?.Dispatcher is null) { return; } await AwaitOnUiThreadAsync( () => { AnalysisProgress.StartBatch(batch.Count); return Task.CompletedTask; }) .ConfigureAwait(false); var progress = new Progress( p => { if (app.Dispatcher.CheckAccess()) { AnalysisProgress.OnProgress(p); } else { app.Dispatcher.BeginInvoke( (Action)(() => AnalysisProgress.OnProgress(p)), DispatcherPriority.DataBind); } }); int errors; try { errors = await _queueAnalysis.RunAsync( batch, item => QueueTasks.Contains(item), autoRemoveForeignTracksForBatch, disableSubtitleDefaultForBatch, progress, RunOnUiActionAsync, token).ConfigureAwait(false); } catch (Exception ex) { _logging.Error($"анализ очереди: {ex.Message}", "conversion.ffprobe", ex); errors = 0; } await AwaitOnUiThreadAsync( () => AnalysisProgress.FinalizeAndHideAsync(errors)) .ConfigureAwait(false); } finally { if (ReferenceEquals(_analysisCts, cts)) { cts.Dispose(); _analysisCts = null; } _batchGate.Release(); } } private static async Task AwaitOnUiThreadAsync(Func work) { var d = Application.Current?.Dispatcher; if (d is null) { await work().ConfigureAwait(false); return; } if (d.CheckAccess()) { await work().ConfigureAwait(true); return; } var tcs = new TaskCompletionSource(); async Task RunOnUi() { try { await work().ConfigureAwait(true); tcs.SetResult(); } catch (Exception ex) { tcs.SetException(ex); } } // Fire-and-forget: RunOnUi completes the TCS; suppress CS4014 for the inner Task and BeginInvoke. #pragma warning disable CS4014 d.BeginInvoke( (Action)(() => { _ = RunOnUi(); }), DispatcherPriority.DataBind); #pragma warning restore CS4014 await tcs.Task.ConfigureAwait(false); } private Task RunOnUiActionAsync(Action action) => AwaitOnUiThreadAsync( () => { action(); return Task.CompletedTask; }); private AddFilesOptions? ShowAddFilesOptionsDialog() { var dialog = new AddFilesOptionsDialog { Owner = Application.Current?.MainWindow }; AddFilesOptions? selected = null; var vm = new AddFilesOptionsViewModel( options => { selected = options; dialog.DialogResult = true; dialog.Close(); }, () => { dialog.DialogResult = false; dialog.Close(); }); dialog.DataContext = vm; var accepted = dialog.ShowDialog() == true; return accepted ? selected : null; } private void RemoveSelectedFromQueue(object? parameter) { RemoveSelectedQueueItems(parameter); } private bool CanRemoveSelectedQueueItems(object? parameter) => !IsExecutionRunning && parameter is IList { Count: > 0 } && !IsInCellEditorContext(); private static bool IsInCellEditorContext() { var focused = Keyboard.FocusedElement; return focused is System.Windows.Controls.TextBox or System.Windows.Controls.Primitives.TextBoxBase or System.Windows.Controls.ComboBox or System.Windows.Controls.ComboBoxItem; } private void RemoveSelectedQueueItems(object? parameter) { if (!CanRemoveSelectedQueueItems(parameter) || parameter is not IList { Count: > 0 } list) { return; } var selected = list.OfType() .Select(item => new { Item = item, Index = QueueTasks.IndexOf(item) }) .Where(x => x.Index >= 0) .OrderBy(x => x.Index) .ToList(); if (selected.Count == 0) { return; } var busyCount = 0; var removable = new List<(ConversionQueueItem Item, int Index)>(); foreach (var x in selected) { if (x.Item.Status is ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing) { busyCount++; } else { removable.Add((x.Item, x.Index)); } } if (removable.Count == 0) { if (busyCount > 0) { _logging.Warning($"Не удалено задач: {busyCount}, так как они выполняются", "conversion.queue"); } return; } var anchorIndex = removable.Min(x => x.Index); foreach (var (item, _) in removable.OrderByDescending(x => x.Index)) { _queuedPaths.Remove(item.FullPath); QueueTasks.Remove(item); } RenumberQueue(); if (QueueTasks.Count > 0) { var nextIndex = Math.Min(anchorIndex, QueueTasks.Count - 1); SelectedQueueItem = QueueTasks[nextIndex]; } else { SelectedQueueItem = null; } _logging.Info($"Удалено задач из очереди: {removable.Count}", "conversion.queue"); if (busyCount > 0) { _logging.Warning($"Не удалено задач: {busyCount}, так как они выполняются", "conversion.queue"); } TouchClearCommand(); RecalculateOverallProgress(); } private bool CanShowInFolder(object? parameter) { if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) { return false; } return FolderCommandsAllowedFor(item) && File.Exists(GetTaskVideoPath(item)); } private bool CanPlayFile(object? parameter) { if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) { return false; } return FolderCommandsAllowedFor(item) && File.Exists(GetTaskVideoPath(item)); } private void ExecuteShowInFolder(object? parameter) { if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) { return; } if (!FolderCommandsAllowedFor(item)) { return; } var path = GetTaskVideoPath(item); try { path = Path.GetFullPath(path); } catch (Exception ex) { _logging.Warning($"некорректный путь для «Показать в папке»: {path} ({ex.Message})", "conversion.queue"); return; } if (!File.Exists(path)) { _logging.Warning($"файл не найден — «Показать в папке»: {path}", "conversion.queue"); return; } try { var escaped = path.Replace("\"", "\\\"", StringComparison.Ordinal); var args = $@"/select,""{escaped}"""; Process.Start( new ProcessStartInfo("explorer.exe", args) { UseShellExecute = true, }); } catch (Exception ex) { _logging.Error($"Не удалось открыть Проводник для файла «{path}»: {ex.Message}", "conversion.queue", ex); } } private void ExecutePlayFile(object? parameter) { if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null) { return; } if (!FolderCommandsAllowedFor(item)) { return; } var path = GetTaskVideoPath(item); try { path = Path.GetFullPath(path); } catch (Exception ex) { _logging.Warning($"некорректный путь для «Воспроизвести»: {path} ({ex.Message})", "conversion.queue"); return; } if (!File.Exists(path)) { _logging.Warning($"файл не найден — «Воспроизвести»: {path}", "conversion.queue"); return; } try { Process.Start( new ProcessStartInfo { FileName = path, UseShellExecute = true, }); } catch (Exception ex) { _logging.Error($"Не удалось воспроизвести файл «{path}»: {ex.Message}", "conversion.queue", ex); } } private bool CanCopyQueueItemError(object? parameter) => ConversionQueueItemErrorCopy.ShouldShowForSelection(parameter as IList); private void ExecuteCopyQueueItemError(object? parameter) { if (!TryGetExactlyOneQueueItem(parameter, out var item) || item is null || !ConversionQueueItemErrorCopy.IsEligibleItem(item)) { return; } var text = ConversionQueueItemErrorCopy.GetClipboardText(item); if (string.IsNullOrEmpty(text)) { return; } try { Clipboard.SetText(text); } catch (Exception ex) { _logging.Warning($"Не удалось скопировать текст в буфер обмена: {ex.Message}", "conversion.queue"); return; } _logging.Info($"Ошибка скопирована в буфер обмена: {item.FileName}", "conversion.queue"); } private static string GetTaskVideoPath(ConversionQueueItem item) => item.FullPath; private static bool FolderCommandsAllowedFor(ConversionQueueItem item) => item.Status is not (ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing); /// Ровно одна выбранная строка очереди (параметр — SelectedItems с DataGrid). private static bool TryGetExactlyOneQueueItem(object? parameter, out ConversionQueueItem? item) { item = null; if (parameter is not IList { Count: 1 } list) { return false; } item = list[0] as ConversionQueueItem; return item is not null; } private static void ResetTaskForReprocessing(ConversionQueueItem item) { item.Status = ConversionQueueStatus.Pending; item.Progress = 0; item.IsProcessed = false; item.ProcessedInCurrentRun = false; item.LastRunId = null; item.ErrorMessage = null; item.ErrorDetails = null; } private void RecalculateOverallProgress() { var all = QueueTasks.ToList(); static bool IsDone(ConversionQueueItem i) => string.Equals(i.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); static bool IsErr(ConversionQueueItem i) => string.Equals(i.Status, ConversionQueueStatus.Error, StringComparison.Ordinal); var qTotal = all.Count; OverallQueueTotal = qTotal; OverallQueueDoneCount = all.Count(IsDone); OverallQueueErrorCount = all.Count(IsErr); if (qTotal == 0) { TotalCount = 0; CompletedCount = 0; OverallProgressPercent = 0; ExecutionPhaseCaption = string.Empty; return; } CompletedCount = OverallQueueDoneCount; TotalCount = qTotal; var runScope = IsExecutionRunning && _currentRunItems.Count > 0 ? _currentRunItems.Where(QueueTasks.Contains).ToList() : null; var effectiveScope = runScope is { Count: > 0 } ? runScope : all; var sumDisplay = 0; foreach (var item in effectiveScope) { sumDisplay += item.DisplayProgressPercent; } var denom = Math.Max(1, effectiveScope.Count); var avgFloor = (int)Math.Floor(sumDisplay / (double)denom); var anyBusyForCap = effectiveScope.Any(IsBusyForOverallProgressCap); OverallProgressPercent = anyBusyForCap ? Math.Min(99, avgFloor) : avgFloor; if (!IsExecutionRunning || runScope is not { Count: > 0 }) { ExecutionPhaseCaption = string.Empty; } else { static bool IsDoneRun(ConversionQueueItem i) => string.Equals(i.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); var totalRun = runScope.Count; var doneInRun = runScope.Count(IsDoneRun); var hasActive = runScope.Any(static i => string.Equals(i.Status, ConversionQueueStatus.Running, StringComparison.Ordinal) || string.Equals(i.Status, ConversionQueueStatus.Copying, StringComparison.Ordinal) || string.Equals(i.Status, ConversionQueueStatus.Replacing, StringComparison.Ordinal) || string.Equals(i.Status, ConversionQueueStatus.Analyzing, StringComparison.Ordinal)); var current = Math.Min(totalRun, Math.Max(1, doneInRun + (hasActive ? 1 : 0))); ExecutionPhaseCaption = $"Конвертация файла {current} из {totalRun}..."; } } private static bool IsBusyForOverallProgressCap(ConversionQueueItem item) { return item.Status switch { ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing => true, ConversionQueueStatus.Analyzing => true, _ => false, }; } private void ReapplySubtitleDefaultRuleToAnalyzedTasks() { if (IsExecutionRunning) { return; } foreach (var item in QueueTasks) { if (item.MediaAnalysis is null) { continue; } var subtitles = item.TaskOverride.TrackOverrides.Where(t => t.StreamKind == MediaStreamKind.Subtitle).ToList(); if (subtitles.Count == 0) { continue; } if (DisableSubtitleDefault) { foreach (var subtitle in subtitles) { subtitle.Default = false; } } var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; var plan = _planService.Build(item.MediaAnalysis, item.Sidecars, profile, item.TaskOverride, item.ExternalAudioFiles); item.SetPlan(plan); } } }