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;
}
}
}