emby-toolbox/EmbyToolbox/ViewModels/SeriesRenamerViewModel.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

335 lines
9.8 KiB
C#

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<string, RenameTreeNodeViewModel> _currentByKey = new(StringComparer.Ordinal);
private readonly Dictionary<string, RenameTreeNodeViewModel> _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<RenameTreeNodeViewModel> CurrentTree { get; } = new();
public ObservableCollection<RenameTreeNodeViewModel> 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();
}
}
/// <summary>Перетаскивание в дерево текущей структуры: только один каталог как корень сериала.</summary>
public void ApplyDroppedPaths(IReadOnlyList<string> 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"
};
}
}