using System.Collections.ObjectModel; using System.ComponentModel; using System.IO; using System.Runtime.CompilerServices; using EmbyToolbox.Services; using Microsoft.Win32; namespace EmbyToolbox.ViewModels; public sealed class SeriesRenamerViewModel : INotifyPropertyChanged { private readonly SeriesRenamerService _service; private readonly LoggingService _logging; private readonly RecentPathService _recentPaths; private string _rootFolderPath = string.Empty; private string _seriesName = string.Empty; private string _unsupportedReason = string.Empty; private bool _isPreviewSupported; private bool _isRootTreeDragOver; private bool _isSynchronizingTrees; private readonly Dictionary _currentByKey = new(StringComparer.Ordinal); private readonly Dictionary _previewByKey = new(StringComparer.Ordinal); private SeriesRenamePreview _currentPreview = SeriesRenamePreview.Unsupported("Папка сериала не выбрана."); public SeriesRenamerViewModel(SeriesRenamerService service, LoggingService logging, RecentPathService recentPaths) { _service = service; _logging = logging; _recentPaths = recentPaths; SelectRootFolderCommand = new RelayCommand(ExecuteSelectRootFolder); RefreshPreviewCommand = new RelayCommand(ExecuteRefreshPreview); RunRenameCommand = new RelayCommand(ExecuteRunRename, () => IsPreviewSupported); } public ObservableCollection CurrentTree { get; } = new(); public ObservableCollection PreviewTree { get; } = new(); public RelayCommand SelectRootFolderCommand { get; } public RelayCommand RefreshPreviewCommand { get; } public RelayCommand RunRenameCommand { get; } public bool IsRootTreeDragOver { get => _isRootTreeDragOver; internal set { if (_isRootTreeDragOver == value) { return; } _isRootTreeDragOver = value; OnPropertyChanged(); } } /// Перетаскивание в дерево текущей структуры: только один каталог как корень сериала. public void ApplyDroppedPaths(IReadOnlyList paths) { if (paths is null || paths.Count == 0) { return; } IsRootTreeDragOver = false; foreach (var raw in paths) { try { var full = Path.GetFullPath(raw); if (Directory.Exists(full)) { ApplyRootFolder(full, fromDragDrop: true); return; } if (File.Exists(full)) { _logging.Warning( $"переименование сериалов: ожидалась папка, файл пропущен: {full}", "series-renamer"); } } catch (Exception ex) { _logging.Warning( $"переименование сериалов: неверный путь «{raw}»: {ex.Message}", "series-renamer"); } } } public string RootFolderPath { get => _rootFolderPath; set { if (_rootFolderPath == value) { return; } _rootFolderPath = value; OnPropertyChanged(); } } public string SeriesName { get => _seriesName; set { if (_seriesName == value) { return; } _seriesName = value; OnPropertyChanged(); RebuildPreview(); } } public bool IsPreviewSupported { get => _isPreviewSupported; private set { if (_isPreviewSupported == value) { return; } _isPreviewSupported = value; OnPropertyChanged(); RunRenameCommand.RaiseCanExecuteChanged(); } } public string UnsupportedReason { get => _unsupportedReason; private set { if (_unsupportedReason == value) { return; } _unsupportedReason = value; OnPropertyChanged(); } } public event PropertyChangedEventHandler? PropertyChanged; private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } private void ExecuteSelectRootFolder() { var dialog = new OpenFolderDialog { Title = "Выберите корневую папку сериала", InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.SeriesRenamer), }; if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName)) { return; } ApplyRootFolder(dialog.FolderName, fromDragDrop: false); } private void ApplyRootFolder(string directoryPath, bool fromDragDrop) { if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath)) { return; } var full = Path.GetFullPath(directoryPath); _recentPaths.RememberChosenFolder(RecentPathScenario.SeriesRenamer, full); RootFolderPath = full; SeriesName = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); _logging.Info( fromDragDrop ? $"папка сериала (drag & drop): {full}" : $"выбрана папка сериала: {full}", "series-renamer"); RebuildPreview(); } private void ExecuteRefreshPreview() { RebuildPreview(); _logging.Debug("предпросмотр переименования обновлен вручную", "series-renamer"); } private void RebuildPreview() { _currentPreview = _service.BuildPreview(RootFolderPath, SeriesName); CurrentTree.Clear(); PreviewTree.Clear(); _currentByKey.Clear(); _previewByKey.Clear(); if (_currentPreview.CurrentTree is not null) { CurrentTree.Add(ConvertNode(_currentPreview.CurrentTree, RenameTreeSide.Current)); } if (_currentPreview.IsSupported && _currentPreview.PreviewTree is not null) { PreviewTree.Add(ConvertNode(_currentPreview.PreviewTree, RenameTreeSide.Preview)); UnsupportedReason = string.Empty; IsPreviewSupported = true; } else { UnsupportedReason = _currentPreview.UnsupportedReason ?? "Невозможно построить предпросмотр."; IsPreviewSupported = false; } } private void ExecuteRunRename() { if (!_currentPreview.IsSupported) { return; } _logging.Info("запуск переименования сериала", "series-renamer"); var result = _service.ExecutePreview(_currentPreview, RootFolderPath); if (result.IsSuccess) { if (result.RootWasRenamed) { RootFolderPath = result.NewRootPath; } _logging.Info("переименование завершено успешно", "series-renamer"); RebuildPreview(); } else { _logging.Error($"ошибка переименования: {result.Error}", "series-renamer"); } } private RenameTreeNodeViewModel ConvertNode(SeriesNode node, RenameTreeSide side) { var vm = new RenameTreeNodeViewModel { Name = node.Name, Kind = node.Kind, IconGlyph = GetGlyph(node.Kind), NodeKey = node.NodeKey, Side = side, IsExpanded = true }; vm.PropertyChanged += OnTreeNodePropertyChanged; if (side == RenameTreeSide.Current) { _currentByKey[node.NodeKey] = vm; } else { _previewByKey[node.NodeKey] = vm; } foreach (var child in node.Children) { vm.Children.Add(ConvertNode(child, side)); } return vm; } private void OnTreeNodePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (_isSynchronizingTrees || !IsPreviewSupported || sender is not RenameTreeNodeViewModel source) { return; } if (e.PropertyName is not nameof(RenameTreeNodeViewModel.IsExpanded) and not nameof(RenameTreeNodeViewModel.IsSelected)) { return; } var targetMap = source.Side == RenameTreeSide.Current ? _previewByKey : _currentByKey; if (!targetMap.TryGetValue(source.NodeKey, out var target)) { return; } try { _isSynchronizingTrees = true; if (e.PropertyName == nameof(RenameTreeNodeViewModel.IsExpanded)) { target.IsExpanded = source.IsExpanded; } else if (e.PropertyName == nameof(RenameTreeNodeViewModel.IsSelected) && source.IsSelected) { target.IsSelected = true; } } finally { _isSynchronizingTrees = false; } } private static string GetGlyph(string kind) { return kind switch { "Folder" => "\uE8B7", "Video" => "\uEDA2", "Sidecar" => "\uE8EA", _ => "\uE8EA" }; } }