484 lines
15 KiB
C#
484 lines
15 KiB
C#
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>На вкладке 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()
|
||
{
|
||
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<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 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;
|
||
}
|
||
}
|
||
|
||
}
|
||
|