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 _formattedJson = 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); CopySummaryCommand = new RelayCommand(ExecuteCopySummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован"); SaveSummaryCommand = new RelayCommand(ExecuteSaveSummary, () => !string.IsNullOrWhiteSpace(SummaryText) && SummaryText != "Файл не проанализирован"); } public ICommand SelectFileCommand { get; } public ICommand SelectSummaryFilesCommand { get; } public ICommand CopySummaryCommand { get; } public ICommand SaveSummaryCommand { 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 string FormattedJson { get => _formattedJson; private set { if (_formattedJson == value) { return; } _formattedJson = 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() { ErrorMessage = string.Empty; FormattedJson = 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 { FormattedJson = FormatJsonOrFallback(result.Json); 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 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() { (CopySummaryCommand as RelayCommand)?.RaiseCanExecuteChanged(); (SaveSummaryCommand as RelayCommand)?.RaiseCanExecuteChanged(); } 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; } } }