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 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 анализирует все поддерживаемые файлы; на detailed — только первый. 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 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(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 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; } } }