2026-05-16 11:45:33 +05:00

860 lines
25 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

using System.Collections.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<MergeFileItem> _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<MergeFileItem> 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);
/// <summary>Итог последней операции объединения (для строки состояния главного окна).</summary>
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<MergeFileItem> 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]);
}
/// <summary>Drag &amp; drop: добавить файлы/каталог (только видео первого уровня) к списку без дублей.</summary>
public void ApplyDroppedPaths(IReadOnlyList<string> rawPaths)
{
if (IsRunning || rawPaths is null || rawPaths.Count == 0)
{
return;
}
IsMergeDropHighlight = false;
var discovered = new List<string>();
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<string>(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<string> 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<string>();
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<int>(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<string> 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<string> sortedFullPaths)
{
if (sortedFullPaths.Count == 0)
{
MergedOutputPath = string.Empty;
return;
}
MergedOutputPath = BuildDefaultMergedOutputFullPath(sortedFullPaths);
}
private string BuildDefaultMergedOutputFullPath(IReadOnlyList<string> 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<MergeFileItem> 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<MergeFileItem>();
var deleteErrors = new List<string>();
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<MergeFileItem> 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));
}
}