using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.IO; using System.Runtime.CompilerServices; using System.Text; using System.Windows; using Microsoft.Win32; using EmbyToolbox.Models; using EmbyToolbox.Services; namespace EmbyToolbox.ViewModels; public sealed class MergeViewModel : INotifyPropertyChanged { private readonly LoggingService _logging; private readonly MergeService _mergeService; private readonly RecentPathService _recentPaths; private string _mergedOutputPath = string.Empty; private bool _isRunning; private int _progressPercent; private string _progressText = "Готово"; private string _validationMessage = string.Empty; private MergeFileItem? _selectedItem; private CancellationTokenSource? _mergeCts; private bool _isMergeDropHighlight; private MergeCompletionKind _lastMergeCompletion = MergeCompletionKind.None; private readonly List _selectedItems = []; public MergeViewModel(LoggingService logging, MergeService mergeService, RecentPathService recentPaths) { _logging = logging; _mergeService = mergeService; _recentPaths = recentPaths; Files.CollectionChanged += OnFilesCollectionChanged; SelectVideoFilesCommand = new RelayCommand(ExecuteSelectVideoFiles, () => !IsRunning); SelectOutputFileCommand = new RelayCommand(ExecuteSelectOutputFile, () => !IsRunning); MoveUpCommand = new RelayCommand(ExecuteMoveUp, CanMoveUp); MoveDownCommand = new RelayCommand(ExecuteMoveDown, CanMoveDown); RefreshCommand = new RelayCommand(ExecuteRefresh, () => !IsRunning && Files.Count > 0); RemoveFromListCommand = new RelayCommand(ExecuteRemoveFromList, CanRemoveFromList); ClearListCommand = new RelayCommand(ExecuteClearList, () => !IsRunning && Files.Count > 0); MergeCommand = new RelayCommand(async () => await ExecuteMergeAsync(), CanMerge); CancelOrClearCommand = new RelayCommand(ExecuteCancelOrClear); } public ObservableCollection Files { get; } = new(); public RelayCommand SelectVideoFilesCommand { get; } public RelayCommand SelectOutputFileCommand { get; } public bool IsMergeDropHighlight { get => _isMergeDropHighlight; internal set { if (_isMergeDropHighlight == value) { return; } _isMergeDropHighlight = value; OnPropertyChanged(); } } public RelayCommand MoveUpCommand { get; } public RelayCommand MoveDownCommand { get; } public RelayCommand RefreshCommand { get; } public RelayCommand RemoveFromListCommand { get; } public RelayCommand ClearListCommand { get; } public RelayCommand MergeCommand { get; } public RelayCommand CancelOrClearCommand { get; } public string MergedOutputPath { get => _mergedOutputPath; set { if (_mergedOutputPath == value) { return; } _mergedOutputPath = value; OnPropertyChanged(); UpdateValidationState(); RaiseCommandStates(); } } public bool IsRunning { get => _isRunning; private set { if (_isRunning == value) { return; } _isRunning = value; OnPropertyChanged(); RaiseCommandStates(); } } public int ProgressPercent { get => _progressPercent; private set { if (_progressPercent == value) { return; } _progressPercent = value; OnPropertyChanged(); } } public string ProgressText { get => _progressText; private set { if (_progressText == value) { return; } _progressText = value; OnPropertyChanged(); } } public string ValidationMessage { get => _validationMessage; private set { if (_validationMessage == value) { return; } _validationMessage = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasValidationMessage)); } } public bool HasValidationMessage => !string.IsNullOrWhiteSpace(ValidationMessage); /// Итог последней операции объединения (для строки состояния главного окна). public MergeCompletionKind LastMergeCompletion => _lastMergeCompletion; public MergeFileItem? SelectedItem { get => _selectedItem; set { if (_selectedItem == value) { return; } _selectedItem = value; OnPropertyChanged(); RaiseCommandStates(); } } public event PropertyChangedEventHandler? PropertyChanged; public void UpdateSelectedItems(IReadOnlyList selectedItems) { _selectedItems.Clear(); _selectedItems.AddRange(selectedItems); RaiseCommandStates(); } private void ExecuteSelectVideoFiles() { var dialog = new OpenFileDialog { Title = "Выберите видеофайлы для объединения", Filter = SupportedVideoFormats.BuildOpenFileDialogFilter(), Multiselect = true, InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.Merge), }; if (dialog.ShowDialog() != true || dialog.FileNames is not { Length: > 0 }) { return; } if (dialog.FileNames.Length > 0) { _recentPaths.RememberChosenFiles(RecentPathScenario.Merge, dialog.FileNames); } LoadFilesFromFilePicker(dialog.FileNames); } private void ExecuteSelectOutputFile() { var suggestFile = "movies.mkv"; string? suggestDir = null; try { if (!string.IsNullOrWhiteSpace(MergedOutputPath)) { var full = Path.GetFullPath(MergedOutputPath.Trim()); suggestDir = Path.GetDirectoryName(full); var fn = Path.GetFileName(full); if (!string.IsNullOrEmpty(fn)) { suggestFile = fn; } } } catch { // ignore malformed path } suggestDir ??= _recentPaths.GetInitialDirectory(RecentPathScenario.Merge); var dlg = new SaveFileDialog { Title = "Итоговый файл", Filter = "Matroska (*.mkv)|*.mkv", DefaultExt = ".mkv", AddExtension = true, FileName = suggestFile, }; if (!string.IsNullOrWhiteSpace(suggestDir) && Directory.Exists(suggestDir)) { dlg.InitialDirectory = suggestDir; } if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.FileName)) { return; } var chosen = Path.GetFullPath(dlg.FileName); MergedOutputPath = chosen; _recentPaths.RememberChosenFiles(RecentPathScenario.Merge, [chosen]); } /// Drag & drop: добавить файлы/каталог (только видео первого уровня) к списку без дублей. public void ApplyDroppedPaths(IReadOnlyList rawPaths) { if (IsRunning || rawPaths is null || rawPaths.Count == 0) { return; } IsMergeDropHighlight = false; var discovered = new List(); foreach (var raw in rawPaths) { try { var full = Path.GetFullPath(raw); if (File.Exists(full)) { if (SupportedVideoFormats.IsSupportedVideoFile(full)) { discovered.Add(full); } else { _logging.Warning($"объединение (drop): не поддерживаемое расширение, пропуск: {full}", "merge"); } continue; } if (Directory.Exists(full)) { foreach (var top in EnumerateTopLevelVideoFilesOnly(full)) { discovered.Add(top); } } } catch (Exception ex) { _logging.Warning($"объединение (drop): не удалось обработать «{raw}»: {ex.Message}", "merge"); } } if (discovered.Count == 0) { _logging.Warning("объединение (drop): нет подходящих видеофайлов", "merge"); return; } var combined = new HashSet(Files.Select(f => f.FullPath), StringComparer.OrdinalIgnoreCase); var added = 0; foreach (var p in discovered) { if (combined.Add(p)) { added++; } } if (added == 0) { _logging.Warning("объединение (drop): все элементы уже в списке", "merge"); return; } var sorted = combined.OrderBy(p => p, FileDiscoveryService.QueuePathOrderComparer).ToList(); RebuildMergeItems(sorted); AfterFilesChanged(); _logging.Info($"объединение (drop): добавлено файлов: {added}, всего в списке: {Files.Count}", "merge"); } private static IEnumerable EnumerateTopLevelVideoFilesOnly(string directoryFullPath) { foreach (var file in Directory.EnumerateFiles(directoryFullPath)) { if (!SupportedVideoFormats.IsSupportedVideoFile(file)) { continue; } string normalized; try { normalized = Path.GetFullPath(file); } catch { continue; } yield return normalized; } } private void LoadFilesFromFilePicker(string[] fileNames) { var list = new List(); foreach (var raw in fileNames) { try { var full = Path.GetFullPath(raw); if (!File.Exists(full)) { continue; } if (!SupportedVideoFormats.IsSupportedVideoFile(full)) { _logging.Warning($"объединение: файл не поддерживается и пропущен: {full}", "merge"); continue; } list.Add(full); } catch (Exception ex) { _logging.Warning($"объединение: неверный путь «{raw}»: {ex.Message}", "merge"); } } var sorted = list.OrderBy(p => p, FileDiscoveryService.QueuePathOrderComparer).ToList(); RebuildMergeItems(sorted); AfterFilesChanged(); _logging.Info($"объединение: выбрано файлов из диалога: {sorted.Count}", "merge"); } private void ExecuteMoveUp() { if (SelectedItem is null) { return; } var idx = Files.IndexOf(SelectedItem); if (idx <= 0) { return; } Files.Move(idx, idx - 1); RenumberMergeRows(); } private void ExecuteMoveDown() { if (SelectedItem is null) { return; } var idx = Files.IndexOf(SelectedItem); if (idx < 0 || idx >= Files.Count - 1) { return; } Files.Move(idx, idx + 1); RenumberMergeRows(); } private void ExecuteRefresh() { if (Files.Count == 0) { return; } var sorted = Files .Select(f => f.FullPath) .OrderBy(p => p, FileDiscoveryService.QueuePathOrderComparer) .ToList(); RebuildMergeItems(sorted); AfterFilesChanged(); _logging.Debug("объединение: порядок обновлён по алфавиту полных путей", "merge"); } private void ExecuteRemoveFromList() { if (_selectedItems.Count == 0 && SelectedItem is null) { return; } var targets = _selectedItems.Count > 0 ? _selectedItems.ToList() : [SelectedItem!]; foreach (var item in targets) { Files.Remove(item); } SelectedItem = null; _selectedItems.Clear(); RenumberMergeRows(); } private void ExecuteClearList() { if (Files.Count == 0) { return; } Files.Clear(); _selectedItems.Clear(); SelectedItem = null; MergedOutputPath = string.Empty; ProgressPercent = 0; ProgressText = "Готово"; ValidationMessage = string.Empty; RaiseCommandStates(); } private void SetLastMergeCompletion(MergeCompletionKind value) { if (_lastMergeCompletion == value) { return; } _lastMergeCompletion = value; OnPropertyChanged(nameof(LastMergeCompletion)); } private async Task ExecuteMergeAsync() { if (!CanMerge()) { return; } string outputPath; try { outputPath = Path.GetFullPath(MergedOutputPath.Trim()); } catch (Exception ex) { ProgressText = $"Ошибка пути: {ex.Message}"; _logging.Error($"объединение: некорректный путь выхода: {ex.Message}", "merge", ex); return; } var outDir = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) { Directory.CreateDirectory(outDir); } var ordered = Files.ToList(); _mergeCts?.Dispose(); _mergeCts = new CancellationTokenSource(); try { SetLastMergeCompletion(MergeCompletionKind.None); IsRunning = true; ProgressPercent = 0; ProgressText = "Объединение..."; foreach (var file in Files) { file.Status = "В очереди"; } var progress = new Progress(p => { ProgressPercent = p; ProgressText = $"Объединение... {p}%"; }); foreach (var file in ordered) { file.Status = "Обработка"; } await _mergeService.MergeAsync(ordered, outputPath, progress, _mergeCts.Token); foreach (var file in Files) { file.Status = "Готово"; } TryDeleteSourcesAfterSuccess(ordered); SetLastMergeCompletion(MergeCompletionKind.Success); ProgressPercent = 100; ProgressText = $"Готово: {Path.GetFileName(outputPath)}"; } catch (OperationCanceledException) { foreach (var file in Files.Where(f => f.Status == "Обработка")) { file.Status = "Отмена"; } SetLastMergeCompletion(MergeCompletionKind.Cancelled); ProgressText = "Операция отменена"; _logging.Warning("объединение отменено пользователем", "merge"); } catch (Exception ex) { foreach (var file in Files) { if (file.Status != "Готово") { file.Status = "Ошибка"; } } SetLastMergeCompletion(MergeCompletionKind.Error); ProgressText = $"Ошибка: {ex.Message}"; _logging.Error($"ошибка объединения: {ex.Message}", "merge", ex); } finally { IsRunning = false; } } private void ExecuteCancelOrClear() { if (IsRunning) { _mergeCts?.Cancel(); return; } SetLastMergeCompletion(MergeCompletionKind.None); Files.Clear(); _selectedItems.Clear(); SelectedItem = null; MergedOutputPath = string.Empty; ProgressPercent = 0; ProgressText = "Готово"; ValidationMessage = string.Empty; } private bool CanMoveUp() { if (IsRunning || SelectedItem is null || _selectedItems.Count > 1) { return false; } return Files.IndexOf(SelectedItem) > 0; } private bool CanMoveDown() { if (IsRunning || SelectedItem is null || _selectedItems.Count > 1) { return false; } var idx = Files.IndexOf(SelectedItem); return idx >= 0 && idx < Files.Count - 1; } private bool CanMerge() { return !IsRunning && ValidateInputs(showMessage: false); } private void RebuildMergeItems(IReadOnlyList sortedFullPaths) { Files.Clear(); for (var i = 0; i < sortedFullPaths.Count; i++) { var fullPath = sortedFullPaths[i]; var fileName = Path.GetFileName(fullPath); var item = new MergeFileItem { FullPath = fullPath, FileName = fileName, SizeMb = (int)Math.Round(new FileInfo(fullPath).Length / (1024d * 1024d), MidpointRounding.AwayFromZero), Number = i + 1, Status = "Готов", }; item.SyncAutoPartName(BuildDefaultPartName(i)); Files.Add(item); } UpdateDefaultMergedOutputPath(sortedFullPaths); } private void UpdateDefaultMergedOutputPath(IReadOnlyList sortedFullPaths) { if (sortedFullPaths.Count == 0) { MergedOutputPath = string.Empty; return; } MergedOutputPath = BuildDefaultMergedOutputFullPath(sortedFullPaths); } private string BuildDefaultMergedOutputFullPath(IReadOnlyList sortedFullPaths) { var parents = sortedFullPaths .Select(static p => Path.GetDirectoryName(p)!) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var dir = parents.Count == 1 ? parents[0] : _recentPaths.GetInitialDirectory(RecentPathScenario.Merge); var fileName = parents.Count == 1 ? $"{new DirectoryInfo(parents[0]).Name}.mkv" : "movies.mkv"; return Path.Combine(dir, fileName); } private static string BuildDefaultPartName(int zeroBasedIndex) { return $"Часть {zeroBasedIndex + 1}"; } private void AfterFilesChanged() { ProgressPercent = 0; ProgressText = Files.Count < 2 ? "Нужно минимум 2 файла для объединения" : "Готово"; UpdateValidationState(); RaiseCommandStates(); } private void RenumberMergeRows() { for (var i = 0; i < Files.Count; i++) { Files[i].Number = i + 1; Files[i].SyncAutoPartName(BuildDefaultPartName(i)); } UpdateValidationState(); RaiseCommandStates(); } private void OnFilesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { UpdateValidationState(); RaiseCommandStates(); } private void UpdateValidationState() { ValidateInputs(showMessage: true); } private bool ValidateInputs(bool showMessage) { string? error = null; if (Files.Count < 2) { error = "Нужно минимум 2 файла для объединения."; } else if (string.IsNullOrWhiteSpace(MergedOutputPath)) { error = "Укажите полный путь итогового файла."; } else { string fullOutput; try { fullOutput = Path.GetFullPath(MergedOutputPath.Trim()); } catch (Exception ex) { error = $"Некорректный путь итогового файла: {ex.Message}"; fullOutput = string.Empty; } if (error is null) { var name = Path.GetFileName(fullOutput); if (string.IsNullOrWhiteSpace(name)) { error = "Укажите имя итогового файла (например, …\\movies.mkv)."; } else if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) { error = "Имя итогового файла содержит недопустимые символы."; } } } if (showMessage) { ValidationMessage = error ?? string.Empty; } return string.IsNullOrWhiteSpace(error); } private void RaiseCommandStates() { SelectVideoFilesCommand.RaiseCanExecuteChanged(); SelectOutputFileCommand.RaiseCanExecuteChanged(); MoveUpCommand.RaiseCanExecuteChanged(); MoveDownCommand.RaiseCanExecuteChanged(); RefreshCommand.RaiseCanExecuteChanged(); RemoveFromListCommand.RaiseCanExecuteChanged(); ClearListCommand.RaiseCanExecuteChanged(); MergeCommand.RaiseCanExecuteChanged(); CancelOrClearCommand.RaiseCanExecuteChanged(); } private bool CanRemoveFromList() { return !IsRunning && (_selectedItems.Count > 0 || SelectedItem is not null); } private void TryDeleteSourcesAfterSuccess(IReadOnlyList ordered) { if (ordered.Count == 0) { return; } var sourceList = string.Join(Environment.NewLine, ordered.Select(f => f.FullPath)); var question = new StringBuilder() .AppendLine("Удалить исходные файлы?") .AppendLine() .AppendLine(sourceList) .ToString(); var answer = MessageBox.Show( question, "Объединение завершено", MessageBoxButton.YesNo, MessageBoxImage.Question); if (answer != MessageBoxResult.Yes) { return; } var deletedItems = new List(); var deleteErrors = new List(); foreach (var item in ordered) { var source = item.FullPath; try { File.Delete(source); deletedItems.Add(item); _logging.Info($"удален исходник после объединения: {source}", "merge"); } catch (Exception ex) { deleteErrors.Add($"{source} ({ex.Message})"); _logging.Warning($"не удалось удалить исходник: {source} ({ex.Message})", "merge", ex); } } RemoveDeletedSourcesFromTable(deletedItems); if (deleteErrors.Count == 0) { return; } MessageBox.Show( "Не удалось удалить некоторые файлы:" + Environment.NewLine + Environment.NewLine + string.Join(Environment.NewLine, deleteErrors), "Удаление исходников", MessageBoxButton.OK, MessageBoxImage.Warning); } private void RemoveDeletedSourcesFromTable(IReadOnlyList deletedItems) { if (deletedItems.Count == 0) { return; } foreach (var item in deletedItems) { Files.Remove(item); _selectedItems.Remove(item); } if (SelectedItem is not null && deletedItems.Contains(SelectedItem)) { SelectedItem = null; } RenumberMergeRows(); } private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }