emby-toolbox/EmbyToolbox/ViewModels/ConversionViewModel.cs
2026-05-16 15:00:24 +05:00

2140 lines
73 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> _tempDirectoryProvider;
private readonly RecentPathService _recentPaths;
private readonly NotificationService _notifications;
private readonly Func<List<ConversionProfileSettingsEntry>> _profilesSnapshotForSetup;
private readonly Func<List<ConversionProfilePresetRow>> _presetRowsForSetup;
private readonly Action<IReadOnlyList<ConversionProfileSettingsEntry>>? _applyProfilesFromSetupDocument;
private readonly BulkTrackSettingsService _bulkTrackSettingsService = new();
private readonly SemaphoreSlim _batchGate = new(1, 1);
private readonly HashSet<string> _queuedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ConversionQueueItem> _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<ConversionQueueItem> _currentRunItems = new();
private string _executionPhaseCaption = string.Empty;
private ConversionQueueItem? _queueItemToReveal;
private bool _copyQueueItemErrorMenuVisible;
private string _toastMessage = string.Empty;
private bool _isToastVisible;
private ToastKind _toastKind;
private DispatcherTimer? _toastHideTimer;
public ObservableCollection<ConversionQueueItem> 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<string> tempDirectoryProvider,
RecentPathService recentPaths,
NotificationService notifications,
Func<List<ConversionProfileSettingsEntry>> profilesSnapshotForSetup,
Func<List<ConversionProfilePresetRow>> presetRowsForSetup,
Action<IReadOnlyList<ConversionProfileSettingsEntry>>? 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<ConversionQueueItem>());
}
CopyQueueItemErrorMenuVisible = ConversionQueueItemErrorCopy.ShouldShowForSelection(selected);
CopyQueueItemErrorCommand.RaiseCanExecuteChanged();
OpenTrackSettingsCommand.RaiseCanExecuteChanged();
OpenBulkFileConversionSettingsCommand.RaiseCanExecuteChanged();
}
/// <summary>Краткая строка для единого прогресса (например «Конвертация файла 3 из 12...»).</summary>
public string ExecutionPhaseCaption
{
get => _executionPhaseCaption;
private set
{
if (_executionPhaseCaption == value)
{
return;
}
_executionPhaseCaption = value;
OnPropertyChanged();
}
}
public ConversionQueueItem? QueueItemToReveal
{
get => _queueItemToReveal;
private set
{
if (ReferenceEquals(_queueItemToReveal, value))
{
return;
}
_queueItemToReveal = value;
OnPropertyChanged();
}
}
/// <summary>Отображаемый общий прогресс (Floor от средних DisplayProgressPercent; без 100%, пока есть активные задачи).</summary>
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();
}
}
/// <summary>Все задачи в очереди (для сводки рядом с прогрессом).</summary>
public int OverallQueueTotal
{
get => _overallQueueTotal;
private set
{
if (_overallQueueTotal == value)
{
return;
}
_overallQueueTotal = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasQueueTasks));
}
}
/// <summary>Завершённые со статусом «Готово».</summary>
public int OverallQueueDoneCount
{
get => _overallQueueDoneCount;
private set
{
if (_overallQueueDoneCount == value)
{
return;
}
_overallQueueDoneCount = value;
OnPropertyChanged();
}
}
/// <summary>Задачи со статусом «Ошибка».</summary>
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;
/// <summary>Автоматически применять настройки дорожек из snapshot предыдущего настроенного файла (если структура совпадает).</summary>
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<ConversionProfilePresetRow> 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();
}
if (s is ConversionQueueItem changedItem
&& e.PropertyName is nameof(ConversionQueueItem.Status) or nameof(ConversionQueueItem.ProcessedInCurrentRun)
&& IsExecutionRunning
&& string.Equals(changedItem.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)
&& changedItem.ProcessedInCurrentRun)
{
QueueItemToReveal = null;
QueueItemToReveal = changedItem;
}
}
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);
}
}
/// <summary>
/// Перед формой дорожек: ffprobe для «Готово» и строк без MediaAnalysis.
/// </summary>
private async Task<bool> EnsureMediaFreshForTrackEditorsAsync(
IReadOnlyList<ConversionQueueItem> 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<TrackOverrideEntry> 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<ConversionQueueItem> ResolveBulkSelection(object? parameter)
{
if (parameter is IList list)
{
return list.OfType<ConversionQueueItem>().Distinct().ToList();
}
return _selectedQueueItems.Distinct().ToList();
}
private static string FormatRowList(IReadOnlyList<int> 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();
}
/// <summary>Удаляются только задачи в статусе «Готово» (Done). Ошибки и отменённые остаются в очереди.</summary>
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<ConversionQueueItem>? 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<ConversionQueueItem>();
IsExecutionRunning = false;
RecalculateOverallProgress();
})
.ConfigureAwait(false);
_execCts?.Dispose();
_execCts = null;
}
}
private void NotifyConversionQueueEnded(
IReadOnlyList<ConversionQueueItem>? 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<string> 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<string> 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<string> videoPaths,
string opTag,
AddFilesOptions addOptions,
string? snapshotScopeExplicitRoot = null)
{
if (videoPaths.Count == 0)
{
_logging.Info("очередь: нет поддерживаемых видео для добавления", "conversion.queue");
return;
}
var profile = string.IsNullOrWhiteSpace(addOptions.Profile) ? "Emby" : addOptions.Profile.Trim();
var added = 0;
var dups = 0;
var newBatch = new List<ConversionQueueItem>();
List<string> 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<ConversionQueueItem> 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<QueueAnalysisProgress>(
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<Task> 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(
_presetRowsForSetup(),
CurrentProfileNameForNewTasks(),
options =>
{
selected = options;
_defaultQueueProfile = options.Profile;
SyncDefaultProfileFromList(_presetRowsForSetup());
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<ConversionQueueItem>()
.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);
/// <summary>Ровно одна выбранная строка очереди (параметр — SelectedItems с DataGrid).</summary>
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);
}
}
}