using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.IO; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Threading; using EmbyToolbox.Models; using EmbyToolbox.Services; using Microsoft.Win32; namespace EmbyToolbox.ViewModels; public sealed class TrackExtractionViewModel : INotifyPropertyChanged { private readonly LoggingService _logging; private readonly TrackExtractionService _service; private readonly RecentPathService _recentPaths; private readonly ExtractCommandBuilder _cmdBuilder = new(); private readonly Dispatcher _dispatcher; private readonly SemaphoreSlim _analyzeGate = new(1, 1); private CancellationTokenSource? _operationCts; private bool _isAnalyzing; private bool _isExtracting; private double _overallProgressPercent; private string _executionPhaseCaption = string.Empty; private TrackExtractionRunOutcome _lastRunOutcome = TrackExtractionRunOutcome.None; private TrackExtractionQueueItem? _selectedItem; private bool _isDropHighlight; private string _destinationFolderPath = string.Empty; public TrackExtractionViewModel(LoggingService logging, TrackExtractionService service, RecentPathService recentPaths) { _logging = logging; _service = service; _recentPaths = recentPaths; _dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; Items.CollectionChanged += OnItemsCollectionChanged; AddFilesCommand = new RelayCommand(ExecuteAddFiles, () => !IsBusy); AddDirectoryCommand = new RelayCommand(ExecuteAddDirectory, () => !IsBusy); ChooseDestinationFolderCommand = new RelayCommand(ExecuteChooseDestinationFolder, () => !IsBusy); StartCommand = new RelayCommand(async () => await ExecuteStartAsync(), CanStart); StopCommand = new RelayCommand(ExecuteStop, () => IsBusy); ClearCommand = new RelayCommand(ExecuteClear, () => !IsBusy && Items.Count > 0); DestinationFolderPath = _recentPaths.GetNormalizedRememberedFolderPath(RecentPathScenario.TrackExtractDestination) ?? string.Empty; } public ObservableCollection Items { get; } = new(); public RelayCommand AddFilesCommand { get; } public RelayCommand AddDirectoryCommand { get; } public RelayCommand ChooseDestinationFolderCommand { get; } public RelayCommand StartCommand { get; } public RelayCommand StopCommand { get; } public RelayCommand ClearCommand { get; } public string DestinationFolderPath { get => _destinationFolderPath; set { if (_destinationFolderPath == value) { return; } _destinationFolderPath = value; OnPropertyChanged(); RaiseCommandStates(); } } public TrackExtractionQueueItem? SelectedItem { get => _selectedItem; set { if (ReferenceEquals(_selectedItem, value)) { return; } _selectedItem = value; OnPropertyChanged(); } } public bool IsDropHighlight { get => _isDropHighlight; internal set { if (_isDropHighlight == value) { return; } _isDropHighlight = value; OnPropertyChanged(); } } public bool IsAnalyzingFiles { get => _isAnalyzing; private set { if (_isAnalyzing == value) { return; } _isAnalyzing = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsBusy)); RaiseCommandStates(); NotifyLongOperationHost(); } } public bool IsExtracting { get => _isExtracting; private set { if (_isExtracting == value) { return; } _isExtracting = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsBusy)); RaiseCommandStates(); NotifyLongOperationHost(); } } public bool IsBusy => IsAnalyzingFiles || IsExtracting; public double OverallProgressPercent { get => _overallProgressPercent; private set { if (Math.Abs(_overallProgressPercent - value) < 0.0001) { return; } _overallProgressPercent = value; OnPropertyChanged(); NotifyLongOperationHost(); } } public string ExecutionPhaseCaption { get => _executionPhaseCaption; private set { if (_executionPhaseCaption == value) { return; } _executionPhaseCaption = value; OnPropertyChanged(); NotifyLongOperationHost(); } } public TrackExtractionRunOutcome LastRunOutcome { get => _lastRunOutcome; private set { if (_lastRunOutcome == value) { return; } _lastRunOutcome = value; OnPropertyChanged(); NotifyLongOperationHost(); } } public event PropertyChangedEventHandler? PropertyChanged; public void ApplyDroppedPaths(IReadOnlyList rawPaths) { if (IsBusy || rawPaths is null || rawPaths.Count == 0) { return; } IsDropHighlight = false; var discovered = new List(); foreach (var raw in rawPaths) { try { var full = Path.GetFullPath(raw); if (File.Exists(full)) { if (TrackExtractionFormats.IsSupportedPath(full)) { discovered.Add(full); } else { _logging.Warning($"извлечение дорожек (drop): пропуск неподдерживаемого файла: {full}", "tracks.extract"); } continue; } if (Directory.Exists(full)) { discovered.AddRange(TrackExtractionFormats.EnumerateMediaFilesRecursive(full)); } } catch (Exception ex) { _logging.Warning($"извлечение дорожек (drop): не удалось обработать «{raw}»: {ex.Message}", "tracks.extract"); } } AddDiscoveredFiles(discovered); } private void ExecuteAddFiles() { var dialog = new OpenFileDialog { Title = "Добавить файлы", Filter = TrackExtractionFormats.BuildOpenFileFilter(), Multiselect = true, }; if (dialog.ShowDialog() != true || dialog.FileNames.Length == 0) { return; } AddDiscoveredFiles(dialog.FileNames); } private void ExecuteAddDirectory() { var dlg = new OpenFolderDialog { Title = "Добавить каталог с видео" }; if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.FolderName)) { return; } var list = TrackExtractionFormats.EnumerateMediaFilesRecursive(dlg.FolderName).ToList(); if (list.Count == 0) { _logging.Warning($"извлечение дорожек: в каталоге не найдено .mkv/.mp4: {dlg.FolderName}", "tracks.extract"); return; } AddDiscoveredFiles(list); } private void ExecuteChooseDestinationFolder() { var extra = string.IsNullOrWhiteSpace(DestinationFolderPath) ? null : DestinationFolderPath.Trim(); var dialog = new OpenFolderDialog { Title = "Папка назначения (будет создан каталог extract)", InitialDirectory = _recentPaths.GetInitialDirectory( RecentPathScenario.TrackExtractDestination, extraFolderFallbackBeforeDefault: extra), }; if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName)) { return; } try { DestinationFolderPath = Path.GetFullPath(dialog.FolderName.Trim()); } catch { DestinationFolderPath = dialog.FolderName.Trim(); } _recentPaths.RememberChosenFolder(RecentPathScenario.TrackExtractDestination, DestinationFolderPath); _logging.Info($"папка назначения извлечения: {DestinationFolderPath}", "tracks.extract"); } private void AddDiscoveredFiles(IReadOnlyList paths) { if (paths.Count == 0) { return; } var existing = new HashSet(Items.Select(i => i.FullPath), StringComparer.OrdinalIgnoreCase); var newlyAdded = new List(); foreach (var p in paths.Distinct(StringComparer.OrdinalIgnoreCase)) { if (!File.Exists(p) || !TrackExtractionFormats.IsSupportedPath(p)) { continue; } if (!existing.Add(p)) { continue; } var item = new TrackExtractionQueueItem(p); Items.Add(item); newlyAdded.Add(item); _logging.Info($"файл добавлен в извлечение дорожек: {Path.GetFileName(p)}", "tracks.extract"); } RenumberRows(); if (newlyAdded.Count > 0) { _ = RunAnalyzeGateAsync(newlyAdded); } RaiseCommandStates(); } private async Task RunAnalyzeGateAsync(List batch) { await _analyzeGate.WaitAsync(); try { _operationCts = new CancellationTokenSource(); var token = _operationCts.Token; IsAnalyzingFiles = true; LastRunOutcome = TrackExtractionRunOutcome.None; OverallProgressPercent = 0; ExecutionPhaseCaption = "Извлечение дорожек — анализ"; var done = 0; foreach (var item in batch) { token.ThrowIfCancellationRequested(); if (!Items.Contains(item)) { continue; } await AnalyzeOneItemAsync(item, token); done++; OverallProgressPercent = batch.Count > 0 ? 100.0 * done / batch.Count : 100; } } catch (OperationCanceledException) { await _dispatcher.InvokeAsync(() => { foreach (var item in batch.Where(i => Items.Contains(i))) { if (item.Status == TrackExtractionStatuses.Analyzing) { item.Status = TrackExtractionStatuses.Cancelled; item.Message = "Остановлено пользователем."; } } }); } catch (Exception ex) { _logging.Error($"извлечение дорожек: сбой пакета анализа: {ex.Message}", "tracks.extract", ex); await _dispatcher.InvokeAsync(() => { foreach (var item in batch.Where(i => Items.Contains(i) && i.Status == TrackExtractionStatuses.Analyzing)) { item.ApplyAnalysisError("Сбой анализа."); } }); } finally { IsAnalyzingFiles = false; _operationCts?.Dispose(); _operationCts = null; OverallProgressPercent = 0; ExecutionPhaseCaption = string.Empty; _analyzeGate.Release(); } } private async Task AnalyzeOneItemAsync(TrackExtractionQueueItem item, CancellationToken token) { await _dispatcher.InvokeAsync(() => { item.Status = TrackExtractionStatuses.Analyzing; item.Message = string.Empty; }); try { var media = await _service.AnalyzeMediaAsync(item.FullPath, _logging, token).ConfigureAwait(false); if (token.IsCancellationRequested) { return; } await _dispatcher.InvokeAsync(() => { if (media is null) { item.ApplyAnalysisError("Не удалось разобрать вывод ffprobe."); } else { item.ApplyAnalysisOk(media); } }); await _dispatcher.InvokeAsync(() => { if (item.MediaAnalysis is not null) { _logging.Info($"анализ ffprobe завершён для «{item.FileName}»", "tracks.extract"); } }); } catch (OperationCanceledException) { throw; } catch (Exception ex) { await _dispatcher.InvokeAsync(() => item.ApplyAnalysisError(ex.Message)); _logging.Error($"ошибка анализа «{item.FileName}»: {ex.Message}", "tracks.extract"); } } private static bool TryNormalizeDestinationTrimmed(string? raw, out string normalizedTrimmed) { normalizedTrimmed = string.Empty; if (string.IsNullOrWhiteSpace(raw)) { return false; } try { normalizedTrimmed = Path.GetFullPath(raw.Trim()); return true; } catch (ArgumentException) { return false; } catch (NotSupportedException) { return false; } } private bool CanStart() { var destOk = TryNormalizeDestinationTrimmed(DestinationFolderPath, out _); return !IsBusy && destOk && Items.Any(i => i.Status == TrackExtractionStatuses.Ready); } private async Task ExecuteStartAsync() { var ready = Items.Where(i => i.Status == TrackExtractionStatuses.Ready && i.MediaAnalysis is not null).ToList(); if (ready.Count == 0) { return; } if (!TryNormalizeDestinationTrimmed(DestinationFolderPath, out var destTrimmed)) { await _dispatcher.InvokeAsync(() => { foreach (var it in ready) { it.Status = TrackExtractionStatuses.Error; it.Message = "Укажите корректную папку назначения."; it.ProgressPercent = 100; } }); LastRunOutcome = TrackExtractionRunOutcome.Error; RaiseCommandStates(); return; } _operationCts = new CancellationTokenSource(); var token = _operationCts.Token; IsExtracting = true; LastRunOutcome = TrackExtractionRunOutcome.None; var totalTracks = ready.Sum(i => Math.Max(i.TotalTracksToExtract, 0)); if (totalTracks <= 0) { totalTracks = ready.Count; } var doneTracks = 0; var runHadErrors = false; var cancelled = false; ExecutionPhaseCaption = "Извлечение дорожек — извлечение"; try { var extractRoot = _service.PrepareExtractLayout(destTrimmed); if (extractRoot is null) { runHadErrors = true; await _dispatcher.InvokeAsync(() => { foreach (var it in ready) { it.Status = TrackExtractionStatuses.Error; it.Message = "Не удалось создать каталог extract. Проверьте папку назначения и доступ."; it.ProgressPercent = 100; } }); _logging.Error("извлечение дорожек: не удалось создать extract в папке назначения", "tracks.extract"); } else { _logging.Info($"подготовлен каталог извлечения: {extractRoot}", "tracks.extract"); var audioDir = Path.Combine(extractRoot, "audio"); var subsDir = Path.Combine(extractRoot, "subtitles"); var attDir = Path.Combine(extractRoot, "attachments"); foreach (var item in ready) { token.ThrowIfCancellationRequested(); await _dispatcher.InvokeAsync(() => { item.Status = TrackExtractionStatuses.Working; item.ProgressPercent = 0; item.Message = string.Empty; }); var media = item.MediaAnalysis!; var totalInFile = Math.Max(item.TotalTracksToExtract, 1); var doneInFile = 0; var fileHadErrors = false; if (item.TotalTracksToExtract <= 0) { doneTracks++; await _dispatcher.InvokeAsync(() => { item.ProgressPercent = 100; item.Status = TrackExtractionStatuses.Done; item.Message = "Дорожек не найдено."; OverallProgressPercent = 100.0 * doneTracks / Math.Max(totalTracks, 1); }); continue; } var stem = ExtractCommandBuilder.SanitizeSourceFileStem(Path.GetFileNameWithoutExtension(item.FullPath)); var a = 0; foreach (var s in media.AudioStreams) { token.ThrowIfCancellationRequested(); a++; var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, a); var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(audioDir, desired); var dest = Path.Combine(audioDir, allocated); if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false)) { fileHadErrors = true; runHadErrors = true; } doneInFile++; doneTracks++; UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks); } var su = 0; foreach (var s in media.SubtitleStreams) { token.ThrowIfCancellationRequested(); su++; var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, su); var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(subsDir, desired); var dest = Path.Combine(subsDir, allocated); if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false)) { fileHadErrors = true; runHadErrors = true; } doneInFile++; doneTracks++; UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks); } var att = 0; foreach (var s in media.AllStreams.Where(x => x.Kind == MediaStreamKind.Attachment)) { token.ThrowIfCancellationRequested(); att++; var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, att); var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(attDir, desired); var dest = Path.Combine(attDir, allocated); if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false)) { fileHadErrors = true; runHadErrors = true; } doneInFile++; doneTracks++; UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks); } await _dispatcher.InvokeAsync(() => { if (item.Status == TrackExtractionStatuses.Cancelled) { return; } if (fileHadErrors) { item.Status = TrackExtractionStatuses.Error; item.ProgressPercent = 100; } else { item.Status = TrackExtractionStatuses.Done; item.Message = "Готово."; item.ProgressPercent = 100; } }); } } } catch (OperationCanceledException) { cancelled = true; LastRunOutcome = TrackExtractionRunOutcome.Cancelled; foreach (var item in Items.Where(i => i.Status == TrackExtractionStatuses.Working)) { await _dispatcher.InvokeAsync(() => { item.Status = TrackExtractionStatuses.Cancelled; item.Message = "Остановлено пользователем."; }); } } catch (Exception ex) { runHadErrors = true; _logging.Error($"извлечение дорожек: неперехваченная ошибка: {ex.Message}", "tracks.extract", ex); await _dispatcher.InvokeAsync(() => { foreach (var item in Items.Where(i => i.Status == TrackExtractionStatuses.Working)) { item.Status = TrackExtractionStatuses.Error; item.Message = ex.Message.Length > 200 ? ex.Message[..200] + "…" : ex.Message; } }); } finally { await _dispatcher.InvokeAsync(() => { IsExtracting = false; _operationCts?.Dispose(); _operationCts = null; OverallProgressPercent = 0; ExecutionPhaseCaption = string.Empty; if (!cancelled) { LastRunOutcome = runHadErrors ? TrackExtractionRunOutcome.Error : TrackExtractionRunOutcome.Success; } RaiseCommandStates(); }); } } private void UpdateRowAndOverall( TrackExtractionQueueItem item, int doneInFile, int totalInFile, int doneTracks, int totalTracks) { var rowPct = 100.0 * doneInFile / totalInFile; var overall = 100.0 * doneTracks / Math.Max(totalTracks, 1); _dispatcher.InvokeAsync(() => { item.ProgressPercent = rowPct; OverallProgressPercent = overall; }); } private async Task ExtractStreamAsync( TrackExtractionQueueItem item, MediaStreamInfo stream, string destinationPath, CancellationToken token) { try { var dir = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrEmpty(dir)) { Directory.CreateDirectory(dir); } var args = _cmdBuilder.BuildFfmpegArgumentList(item.FullPath, stream, destinationPath); var (ok, err) = await _service.RunExtractProcessAsync(args, token).ConfigureAwait(false); if (ok) { await _dispatcher.InvokeAsync(() => { var shortName = Path.GetFileName(destinationPath); item.Message = $"Извлечено: {shortName}"; }); _logging.Info($"извлечена дорожка [{stream.Kind}] #{stream.Index} → {destinationPath}", "tracks.extract"); return true; } var msg = string.IsNullOrWhiteSpace(err) ? $"ffmpeg ошибка дорожки {stream.Index}" : err.Trim(); await _dispatcher.InvokeAsync(() => { item.Status = TrackExtractionStatuses.Error; item.Message = msg.Length > 200 ? msg[..200] + "…" : msg; }); _logging.Error($"ошибка извлечения дорожки #{stream.Index} из «{item.FileName}»: {msg}", "tracks.extract"); return false; } catch (OperationCanceledException) { throw; } catch (Exception ex) { await _dispatcher.InvokeAsync(() => { item.Status = TrackExtractionStatuses.Error; item.Message = ex.Message; }); _logging.Error($"ошибка извлечения «{item.FileName}»: {ex.Message}", "tracks.extract", ex); return false; } } private void ExecuteStop() { try { _operationCts?.Cancel(); } catch { // ignore } } private void ExecuteClear() { if (IsBusy) { return; } Items.Clear(); SelectedItem = null; LastRunOutcome = TrackExtractionRunOutcome.None; RenumberRows(); RaiseCommandStates(); } private void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { RenumberRows(); RaiseCommandStates(); } private void RenumberRows() { var n = 1; foreach (var i in Items) { i.RowNumber = n++; } } private void RaiseCommandStates() { AddFilesCommand.RaiseCanExecuteChanged(); AddDirectoryCommand.RaiseCanExecuteChanged(); ChooseDestinationFolderCommand.RaiseCanExecuteChanged(); StartCommand.RaiseCanExecuteChanged(); StopCommand.RaiseCanExecuteChanged(); ClearCommand.RaiseCanExecuteChanged(); } private void NotifyLongOperationHost() { if (_dispatcher.CheckAccess()) { OnPropertyChanged(nameof(IsBusy)); } else { _dispatcher.BeginInvoke(() => OnPropertyChanged(nameof(IsBusy))); } } private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }