2142 lines
73 KiB
C#
2142 lines
73 KiB
C#
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 = addOptions.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(),
|
||
DisableSubtitleDefault,
|
||
options =>
|
||
{
|
||
selected = options;
|
||
_defaultQueueProfile = options.Profile;
|
||
DisableSubtitleDefault = options.DisableSubtitleDefault;
|
||
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);
|
||
}
|
||
}
|
||
}
|