emby-toolbox/EmbyToolbox/ViewModels/VideoInfoViewModel.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

712 lines
22 KiB
C#
Raw 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.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Linq;
using System.Windows;
using System.Windows.Input;
using System.IO;
using EmbyToolbox.Models;
using EmbyToolbox.Services;
using Microsoft.Win32;
namespace EmbyToolbox.ViewModels;
public sealed class VideoInfoViewModel : INotifyPropertyChanged
{
private readonly FfprobeService _ffprobeService;
private readonly LoggingService _logging;
private readonly RecentPathService _recentPaths;
private readonly SidecarDiscoveryService _sidecarDiscoveryService;
private readonly VideoInfoSummaryService _summaryService;
private string _selectedFilePath = string.Empty;
private string _analysisStateText = string.Empty;
private string _errorMessage = string.Empty;
private string _rawJson = string.Empty;
private string _summaryText = string.Empty;
private bool _isBusy;
private bool _isVideoInfoDropHighlight;
private int _selectedSubTabIndex;
public VideoInfoViewModel(
FfprobeService ffprobeService,
LoggingService logging,
RecentPathService recentPaths,
SidecarDiscoveryService sidecarDiscoveryService,
VideoInfoSummaryService summaryService)
{
_ffprobeService = ffprobeService;
_logging = logging;
_recentPaths = recentPaths;
_sidecarDiscoveryService = sidecarDiscoveryService;
_summaryService = summaryService;
SelectFileCommand = new RelayCommand(ExecuteSelectFile);
SelectSummaryFilesCommand = new RelayCommand(ExecuteSelectSummaryFiles);
ExpandAllCommand = new RelayCommand(ExecuteExpandAll);
CollapseAllCommand = new RelayCommand(ExecuteCollapseAll);
CopyJsonCommand = new RelayCommand(ExecuteCopyJson, () => !string.IsNullOrWhiteSpace(_rawJson));
SaveJsonCommand = new RelayCommand(ExecuteSaveJson, () => !string.IsNullOrWhiteSpace(_rawJson));
CopySummaryCommand = new RelayCommand(ExecuteCopySummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован");
SaveSummaryCommand = new RelayCommand(ExecuteSaveSummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован");
CopyNodeValueCommand = new RelayCommand(ExecuteCopyNodeValue, CanCopyNodeValue);
CopyNodeLineCommand = new RelayCommand(ExecuteCopyNodeLine, CanCopyNodeLine);
CopyNodeWithChildrenCommand = new RelayCommand(ExecuteCopyNodeWithChildren, CanCopyNodeWithChildren);
CopyNodePathCommand = new RelayCommand(ExecuteCopyNodePath, CanCopyNodePath);
}
public ObservableCollection<JsonTreeNodeViewModel> JsonNodes { get; } = new();
public ICommand SelectFileCommand { get; }
public ICommand SelectSummaryFilesCommand { get; }
public ICommand ExpandAllCommand { get; }
public ICommand CollapseAllCommand { get; }
public ICommand CopyJsonCommand { get; }
public ICommand SaveJsonCommand { get; }
public ICommand CopySummaryCommand { get; }
public ICommand SaveSummaryCommand { get; }
public ICommand CopyNodeValueCommand { get; }
public ICommand CopyNodeLineCommand { get; }
public ICommand CopyNodeWithChildrenCommand { get; }
public ICommand CopyNodePathCommand { get; }
public string SelectedFilePath
{
get => _selectedFilePath;
private set
{
if (_selectedFilePath == value)
{
return;
}
_selectedFilePath = value;
OnPropertyChanged();
}
}
public string AnalysisStateText
{
get => _analysisStateText;
private set
{
if (_analysisStateText == value)
{
return;
}
_analysisStateText = value;
OnPropertyChanged();
}
}
public string ErrorMessage
{
get => _errorMessage;
private set
{
if (_errorMessage == value)
{
return;
}
_errorMessage = value;
OnPropertyChanged();
}
}
public bool IsBusy
{
get => _isBusy;
private set
{
if (_isBusy == value)
{
return;
}
_isBusy = value;
OnPropertyChanged();
}
}
public int SelectedSubTabIndex
{
get => _selectedSubTabIndex;
set
{
if (_selectedSubTabIndex == value)
{
return;
}
_selectedSubTabIndex = value;
OnPropertyChanged();
}
}
public string SummaryText
{
get => _summaryText;
private set
{
if (_summaryText == value)
{
return;
}
_summaryText = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasSummaryData));
}
}
public bool HasSummaryData => !string.IsNullOrWhiteSpace(_summaryText);
public bool IsVideoInfoDropHighlight
{
get => _isVideoInfoDropHighlight;
internal set
{
if (_isVideoInfoDropHighlight == value)
{
return;
}
_isVideoInfoDropHighlight = value;
OnPropertyChanged();
}
}
/// <summary>На вкладке summary анализирует все поддерживаемые файлы; на detailed — только первый.</summary>
public void ApplyDroppedPathsAndAnalyze(string[]? paths)
{
if (paths is null || paths.Length == 0)
{
return;
}
IsVideoInfoDropHighlight = false;
if (IsBusy)
{
_logging.Warning("video-info (drop): анализ уже выполняется — повторите после завершения", "video-info");
return;
}
_ = SelectedSubTabIndex == 0
? ApplyDroppedSummaryInternalAsync(paths)
: ApplyDroppedDetailedInternalAsync(paths);
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private async void ExecuteSelectFile()
{
var dialog = new OpenFileDialog
{
Title = "Выберите видеофайл",
Filter = SupportedVideoFormats.BuildOpenFileDialogFilter(),
InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.VideoInfoOpenFile),
};
if (dialog.ShowDialog() != true)
{
return;
}
if (!SupportedVideoFormats.IsSupportedVideoFile(dialog.FileName))
{
_logging.Warning($"video-info: формат не поддерживается: {dialog.FileName}", "video-info");
return;
}
_recentPaths.RememberChosenFiles(RecentPathScenario.VideoInfoOpenFile, [dialog.FileName]);
SelectedFilePath = dialog.FileName;
_logging.Info($"выбран файл: {dialog.FileName}", "video-info");
_logging.Debug("запуск ffprobe", "video-info.ffprobe");
await AnalyzeAsync();
}
private async void ExecuteSelectSummaryFiles()
{
var dialog = new OpenFileDialog
{
Title = "Выберите видеофайлы",
Filter = SupportedVideoFormats.BuildOpenFileDialogFilter(),
InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.VideoInfoOpenFile),
Multiselect = true
};
if (dialog.ShowDialog() != true || dialog.FileNames.Length == 0)
{
return;
}
_recentPaths.RememberChosenFiles(RecentPathScenario.VideoInfoOpenFile, dialog.FileNames);
await AnalyzeSummaryFilesAsync(dialog.FileNames);
}
private async Task ApplyDroppedDetailedInternalAsync(string[] paths)
{
try
{
foreach (var raw in paths.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
string full;
try
{
full = Path.GetFullPath(raw);
}
catch (Exception ex)
{
_logging.Warning($"video-info (drop): путь «{raw}»: {ex.Message}", "video-info");
continue;
}
if (!File.Exists(full))
{
continue;
}
if (!SupportedVideoFormats.IsSupportedVideoFile(full))
{
_logging.Warning($"video-info (drop): формат не поддерживается: {full}", "video-info");
continue;
}
_recentPaths.RememberChosenFiles(RecentPathScenario.VideoInfoOpenFile, [full]);
SelectedFilePath = full;
_logging.Info($"video-info (drop): запуск анализа {full}", "video-info");
await AnalyzeAsync();
return;
}
_logging.Warning("video-info (drop): ни один поддерживаемый видеофайл не найден", "video-info");
}
catch (Exception ex)
{
_logging.Error($"video-info (drop): {ex.Message}", "video-info", ex);
}
}
private async Task AnalyzeAsync()
{
JsonNodes.Clear();
ErrorMessage = string.Empty;
_rawJson = string.Empty;
RaiseCommandStates();
if (string.IsNullOrWhiteSpace(SelectedFilePath))
{
AnalysisStateText = string.Empty;
return;
}
IsBusy = true;
AnalysisStateText = "Анализ файла...";
var result = await _ffprobeService.AnalyzeAsync(SelectedFilePath);
IsBusy = false;
if (!result.IsSuccess)
{
AnalysisStateText = "Ошибка анализа";
ErrorMessage = result.Error;
_logging.Error($"ffprobe: {result.Error}", "video-info.ffprobe", command: result.Command, stdout: result.StdOut, stderr: result.StdErr);
RaiseCommandStates();
return;
}
try
{
_rawJson = result.Json;
BuildTree(_rawJson);
AnalysisStateText = "Готово";
RaiseCommandStates();
_logging.Info($"ffprobe завершен: {Path.GetFileName(SelectedFilePath)}", "video-info.ffprobe", command: result.Command, stdout: result.StdOut, stderr: result.StdErr);
}
catch (Exception ex)
{
AnalysisStateText = "Ошибка анализа";
ErrorMessage = $"Не удалось разобрать JSON ffprobe: {ex.Message}";
_logging.Error($"parse json: {ex.Message}", "video-info.json", ex);
RaiseCommandStates();
}
}
private async Task ApplyDroppedSummaryInternalAsync(string[] paths)
{
try
{
await AnalyzeSummaryFilesAsync(paths);
}
catch (Exception ex)
{
_logging.Error($"video-info summary (drop): {ex.Message}", "video-info", ex);
}
}
private async Task AnalyzeSummaryFilesAsync(IEnumerable<string> rawPaths)
{
SummaryText = string.Empty;
ErrorMessage = string.Empty;
AnalysisStateText = "Анализ файла...";
var supportedFiles = rawPaths
.Select(
p =>
{
try
{
return Path.GetFullPath(p);
}
catch
{
return string.Empty;
}
})
.Where(p => !string.IsNullOrWhiteSpace(p))
.Where(File.Exists)
.Where(SupportedVideoFormats.IsSupportedVideoFile)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
if (supportedFiles.Count == 0)
{
AnalysisStateText = string.Empty;
return;
}
IsBusy = true;
var all = new List<string>(supportedFiles.Count * 8);
foreach (var file in supportedFiles)
{
var probe = await _ffprobeService.AnalyzeAsync(file);
if (!probe.IsSuccess)
{
all.Add($"{file}{Environment.NewLine}Ошибка анализа: {probe.Error}");
all.Add(string.Empty);
continue;
}
var media = MediaAnalysisParser.TryParse(probe.Json);
if (media is null)
{
all.Add($"{file}{Environment.NewLine}Ошибка разбора ffprobe JSON");
all.Add(string.Empty);
continue;
}
var sidecarResult = await _sidecarDiscoveryService.DiscoverAsync(file, _ffprobeService).ConfigureAwait(true);
var summarySidecars = new SidecarAnalysisResult(file, sidecarResult.Sidecars, sidecarResult.ExternalAudioFiles);
all.Add(file);
all.Add(_summaryService.BuildSummary(media, summarySidecars));
all.Add(string.Empty);
}
IsBusy = false;
SummaryText = string.Join(Environment.NewLine, all).Trim();
AnalysisStateText = "Готово";
RaiseCommandStates();
}
private void BuildTree(string json)
{
using var doc = JsonDocument.Parse(json);
JsonNodes.Clear();
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in doc.RootElement.EnumerateObject())
{
JsonNodes.Add(CreateNode(property.Name, property.Value, null));
}
}
else
{
JsonNodes.Add(CreateNode("root", doc.RootElement, null));
}
}
private static JsonTreeNodeViewModel CreateNode(string name, JsonElement element, JsonTreeNodeViewModel? parent)
{
var node = new JsonTreeNodeViewModel(name, GetPreviewValue(element), element.GetRawText(), parent);
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var prop in element.EnumerateObject())
{
node.Children.Add(CreateNode(prop.Name, prop.Value, node));
}
break;
case JsonValueKind.Array:
var index = 0;
foreach (var item in element.EnumerateArray())
{
node.Children.Add(CreateNode($"[{index}]", item, node));
index++;
}
break;
}
return node;
}
private static string GetPreviewValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => "{...}",
JsonValueKind.Array => "[...]",
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number => element.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => element.GetRawText()
};
}
private void ExecuteExpandAll()
{
SetExpandedState(JsonNodes, true);
}
private void ExecuteCollapseAll()
{
SetExpandedState(JsonNodes, false);
}
private static void SetExpandedState(IEnumerable<JsonTreeNodeViewModel> nodes, bool isExpanded)
{
foreach (var node in nodes)
{
node.IsExpanded = isExpanded;
SetExpandedState(node.Children, isExpanded);
}
}
private void ExecuteCopyJson()
{
if (string.IsNullOrWhiteSpace(_rawJson))
{
return;
}
Clipboard.SetText(_rawJson);
_logging.Info("JSON скопирован в буфер обмена", "video-info.copy");
}
private void ExecuteSaveJson()
{
if (string.IsNullOrWhiteSpace(_rawJson))
{
return;
}
var defaultName = string.IsNullOrWhiteSpace(SelectedFilePath)
? "ffprobe.json"
: $"{Path.GetFileNameWithoutExtension(SelectedFilePath)}.ffprobe.json";
var dialog = new SaveFileDialog
{
Title = "Сохранить JSON ffprobe",
Filter = "JSON (*.json)|*.json|Все файлы|*.*",
FileName = defaultName,
InitialDirectory = _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder),
};
if (dialog.ShowDialog() != true)
{
return;
}
File.WriteAllText(dialog.FileName, _rawJson);
_recentPaths.RememberChosenFolder(
RecentPathScenario.SettingsOutputFolder,
Path.GetDirectoryName(dialog.FileName) ?? dialog.FileName);
_logging.Info($"JSON сохранен: {dialog.FileName}", "video-info.save");
}
private void ExecuteCopySummary()
{
if (string.IsNullOrWhiteSpace(SummaryText) || SummaryText == "Файл не проанализирован")
{
return;
}
Clipboard.SetText(SummaryText);
_logging.Info("summary скопирован в буфер обмена", "video-info.copy");
}
private void ExecuteSaveSummary()
{
if (string.IsNullOrWhiteSpace(SummaryText) || SummaryText == "Файл не проанализирован")
{
return;
}
var defaultName = string.IsNullOrWhiteSpace(SelectedFilePath)
? "video-summary.txt"
: $"{Path.GetFileNameWithoutExtension(SelectedFilePath)}.summary.txt";
var initialDir = string.IsNullOrWhiteSpace(SelectedFilePath)
? _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder)
: Path.GetDirectoryName(SelectedFilePath) ?? _recentPaths.GetInitialDirectory(RecentPathScenario.SettingsOutputFolder);
var dialog = new SaveFileDialog
{
Title = "Сохранить summary",
Filter = "Text (*.txt)|*.txt|Все файлы|*.*",
FileName = defaultName,
InitialDirectory = initialDir
};
if (dialog.ShowDialog() != true)
{
return;
}
File.WriteAllText(dialog.FileName, SummaryText);
_recentPaths.RememberChosenFolder(
RecentPathScenario.SettingsOutputFolder,
Path.GetDirectoryName(dialog.FileName) ?? dialog.FileName);
_logging.Info($"summary сохранен: {dialog.FileName}", "video-info.save");
}
private void RaiseCommandStates()
{
(CopyJsonCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveJsonCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopySummaryCommand as RelayCommand)?.RaiseCanExecuteChanged();
(SaveSummaryCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeValueCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeLineCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodeWithChildrenCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CopyNodePathCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
private static JsonTreeNodeViewModel? AsNode(object? parameter)
{
return parameter as JsonTreeNodeViewModel;
}
private bool CanCopyNodeValue(object? parameter)
{
var node = AsNode(parameter);
return node is not null && node.Children.Count == 0;
}
private bool CanCopyNodeLine(object? parameter)
{
return AsNode(parameter) is not null;
}
private bool CanCopyNodeWithChildren(object? parameter)
{
return AsNode(parameter) is not null;
}
private bool CanCopyNodePath(object? parameter)
{
return AsNode(parameter) is not null;
}
private void ExecuteCopyNodeValue(object? parameter)
{
var node = AsNode(parameter);
if (node is null || node.Children.Count > 0)
{
return;
}
Clipboard.SetText(node.Value);
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodeLine(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
Clipboard.SetText($"{node.Name}: {node.Value}");
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodeWithChildren(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
var formatted = FormatJsonOrFallback(node.SubtreeJson);
Clipboard.SetText(formatted);
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private void ExecuteCopyNodePath(object? parameter)
{
var node = AsNode(parameter);
if (node is null)
{
return;
}
Clipboard.SetText(BuildNodePath(node));
_logging.Info("узел JSON скопирован", "video-info.copy");
}
private static string BuildNodePath(JsonTreeNodeViewModel node)
{
if (node.Parent is null)
{
return node.Name;
}
var parentPath = BuildNodePath(node.Parent);
if (node.Name.StartsWith("[", StringComparison.Ordinal))
{
return $"{parentPath}{node.Name}";
}
return string.IsNullOrWhiteSpace(parentPath) ? node.Name : $"{parentPath}.{node.Name}";
}
private static string FormatJsonOrFallback(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
catch
{
return json;
}
}
}