From f5c6cc74384c1625b8b9fd9027dd1ede88d27f53 Mon Sep 17 00:00:00 2001 From: Emby Toolbox Date: Tue, 12 May 2026 21:48:52 +0500 Subject: [PATCH] Allow track settings for completed tasks: re-probe file, keep Done until save; reset to queue on changes. Co-authored-by: Cursor --- EmbyToolbox/Services/QueueAnalysisService.cs | 112 ++++++++++++ EmbyToolbox/ViewModels/ConversionViewModel.cs | 170 +++++++++++++++--- 2 files changed, 253 insertions(+), 29 deletions(-) diff --git a/EmbyToolbox/Services/QueueAnalysisService.cs b/EmbyToolbox/Services/QueueAnalysisService.cs index b1401ea..1f866e4 100644 --- a/EmbyToolbox/Services/QueueAnalysisService.cs +++ b/EmbyToolbox/Services/QueueAnalysisService.cs @@ -1,4 +1,7 @@ +using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using EmbyToolbox.Models; namespace EmbyToolbox.Services; @@ -6,6 +9,9 @@ namespace EmbyToolbox.Services; /// Ход пакетного анализа очереди (ffprobe) для IProgress и UI. public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount); +/// Поля завершённой задачи, которые восстанавливаются после повторного ffprobe (форма дорожек). +public readonly record struct DoneCompletionState(int Progress, string? LastRunId, bool IsProcessed, bool ProcessedInCurrentRun); + /// /// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по , /// обновление строки через (UI). @@ -33,6 +39,112 @@ public sealed class QueueAnalysisService _profile = profile; } + /// + /// Повторный анализ текущего файла на диске (актуально после «Готово» или при отсутствии MediaAnalysis). + /// Пересобирает дорожки и план; если передан — статус остаётся «Готово». + /// + public async Task RefreshItemMediaForTrackEditorAsync( + ConversionQueueItem item, + bool autoRemoveForeignTracks, + bool disableSubtitleDefault, + DoneCompletionState? preserveCompletion, + Func uiInvoke, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(item.FullPath) || !File.Exists(item.FullPath)) + { + _logging.Warning($"редактор дорожек: файл не найден — {item.FullPath}", "conversion.queue"); + return false; + } + + FfprobeResult result; + try + { + result = await _ffprobe.AnalyzeAsync(item.FullPath, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return false; + } + + if (!result.IsSuccess) + { + _logging.Error( + $"редактор дорожек: ffprobe — {result.Error} — {item.FullPath}", + "conversion.ffprobe", + stderr: result.StdErr); + return false; + } + + var media = MediaAnalysisParser.TryParse(result.Json); + if (media is null) + { + _logging.Error($"редактор дорожек: неверный JSON ffprobe — {item.FullPath}", "conversion.ffprobe"); + return false; + } + + SidecarDiscoveryResult discovery; + try + { + discovery = await _sidecar.DiscoverAsync(item.FullPath, _ffprobe, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) + { + _logging.Error($"редактор дорожек: sidecar — {ex.Message} — {item.FullPath}", "conversion.sidecar", ex); + discovery = new SidecarDiscoveryResult(Array.Empty(), Array.Empty()); + } + + var audio = FfprobeAudioInfoParser.TryParse(result.Json) ?? new FfprobeAudioInfo(0, null, true); + await uiInvoke( + () => + { + var profile = _profile.GetProfile(item.Profile) ?? ConversionProfileMapping.EmbyFallback; + item.TaskOverride.TrackOverrides.Clear(); + TrackOverrideSeeder.EnsureDefaults( + item.TaskOverride, + media, + discovery.Sidecars, + profile, + autoRemoveForeignTracks, + discovery.ExternalAudioFiles, + item.FullPath, + sidecarTitleResolver: null, + logging: _logging, + disableSubtitleDefault: disableSubtitleDefault); + var plan = _planService.Build(media, discovery.Sidecars, profile, item.TaskOverride, discovery.ExternalAudioFiles); + item.SetSuccessfulMediaAnalysis( + media, + discovery.Sidecars, + discovery.ExternalAudioFiles, + plan, + audio.AudioStreamCount, + audio.AudioSizeMbTotal, + audio.IsPartial); + if (preserveCompletion is { } d) + { + item.Status = ConversionQueueStatus.Done; + item.Progress = d.Progress; + item.LastRunId = d.LastRunId; + item.IsProcessed = d.IsProcessed; + item.ProcessedInCurrentRun = d.ProcessedInCurrentRun; + } + else + { + item.Status = ConversionQueueStatus.Pending; + item.Progress = 0; + item.ErrorMessage = null; + item.ErrorDetails = null; + } + }) + .ConfigureAwait(false); + + return true; + } + public async Task RunAsync( IReadOnlyList items, Func isStillInQueue, diff --git a/EmbyToolbox/ViewModels/ConversionViewModel.cs b/EmbyToolbox/ViewModels/ConversionViewModel.cs index 34b0252..72f3f09 100644 --- a/EmbyToolbox/ViewModels/ConversionViewModel.cs +++ b/EmbyToolbox/ViewModels/ConversionViewModel.cs @@ -5,6 +5,8 @@ using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Data; using System.Windows.Input; @@ -107,9 +109,13 @@ public sealed class ConversionViewModel : INotifyPropertyChanged PlayFileCommand = new RelayCommand(ExecutePlayFile, CanPlayFile); ClearQueueCommand = new RelayCommand(ExecuteClearQueue, () => QueueTasks.Count > 0 && !IsExecutionRunning); ClearCompletedFromQueueCommand = new RelayCommand(ExecuteClearCompletedFromQueue, CanClearCompletedFromQueue); - OpenFileConversionSettingsCommand = new RelayCommand(ExecuteOpenFileSettings, p => !IsExecutionRunning && p is ConversionQueueItem i && i.MediaAnalysis is not null); + OpenFileConversionSettingsCommand = new RelayCommand( + p => { _ = ExecuteOpenFileSettingsAsync(p); }, + CanExecuteOpenFileSettings); OpenTrackSettingsCommand = new RelayCommand(ExecuteOpenTrackSettingsFromKeyboard, CanOpenTrackSettingsFromKeyboard); - OpenBulkFileConversionSettingsCommand = new RelayCommand(ExecuteOpenBulkFileSettings, CanOpenBulkFileSettings); + OpenBulkFileConversionSettingsCommand = new RelayCommand( + p => { _ = ExecuteOpenBulkFileSettingsAsync(p); }, + CanOpenBulkFileSettings); StartProcessingCommand = new RelayCommand(ExecuteStartProcessing, () => !IsExecutionRunning); StopProcessingCommand = new RelayCommand(ExecuteStopProcessing, () => IsExecutionRunning); CopyQueueItemErrorCommand = new RelayCommand(ExecuteCopyQueueItemError, CanCopyQueueItemError); @@ -603,33 +609,128 @@ public sealed class ConversionViewModel : INotifyPropertyChanged } } - private void ExecuteOpenFileSettings(object? parameter) + private bool CanExecuteOpenFileSettings(object? p) => + !IsExecutionRunning + && p is ConversionQueueItem i + && i.Status is not ( + ConversionQueueStatus.Analyzing + or ConversionQueueStatus.Running + or ConversionQueueStatus.Copying + or ConversionQueueStatus.Replacing) + && !string.IsNullOrWhiteSpace(i.FullPath) + && File.Exists(i.FullPath); + + private async Task ExecuteOpenFileSettingsAsync(object? parameter) { - if (IsExecutionRunning) + try { - return; + if (IsExecutionRunning || parameter is not ConversionQueueItem item) + { + return; + } + + var needsProbe = item.MediaAnalysis is null + || string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); + if (needsProbe) + { + DoneCompletionState? preserve = null; + if (string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)) + { + preserve = new DoneCompletionState( + item.Progress, + item.LastRunId, + item.IsProcessed, + item.ProcessedInCurrentRun); + } + + var ok = await _queueAnalysis.RefreshItemMediaForTrackEditorAsync( + item, + autoRemoveForeignTracks: false, + DisableSubtitleDefault, + preserve, + RunOnUiActionAsync, + CancellationToken.None) + .ConfigureAwait(true); + if (!ok) + { + ShowToast("Не удалось прочитать дорожки файла (ffprobe).", ToastKind.Warning); + return; + } + } + + if (item.MediaAnalysis is null) + { + return; + } + + var w = new FileConversionSettingsWindow + { + Owner = Application.Current?.MainWindow + }; + w.DataContext = new FileConversionSettingsViewModel( + item, + _planService, + _profile, + _trackSnapshotService, + _logging, + FormOptions, + CopyPreviousTrackSettings, + OnFileSettingsSaved, + () => w.Close()); + w.ShowDialog(); + } + catch (Exception ex) + { + _logging.Error($"форма дорожек: {ex.Message}", "conversion.ui", ex); + } + } + + /// + /// Перед формой дорожек: ffprobe для «Готово» и строк без MediaAnalysis. + /// + private async Task EnsureMediaFreshForTrackEditorsAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + var needsProbe = item.MediaAnalysis is null + || string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal); + if (!needsProbe) + { + continue; + } + + if (string.IsNullOrWhiteSpace(item.FullPath) || !File.Exists(item.FullPath)) + { + return false; + } + + DoneCompletionState? preserve = null; + if (string.Equals(item.Status, ConversionQueueStatus.Done, StringComparison.Ordinal)) + { + preserve = new DoneCompletionState( + item.Progress, + item.LastRunId, + item.IsProcessed, + item.ProcessedInCurrentRun); + } + + var ok = await _queueAnalysis.RefreshItemMediaForTrackEditorAsync( + item, + autoRemoveForeignTracks: false, + DisableSubtitleDefault, + preserve, + RunOnUiActionAsync, + cancellationToken) + .ConfigureAwait(true); + if (!ok) + { + return false; + } } - if (parameter is not ConversionQueueItem item || item.MediaAnalysis is null) - { - return; - } - - var w = new FileConversionSettingsWindow - { - Owner = Application.Current?.MainWindow - }; - w.DataContext = new FileConversionSettingsViewModel( - item, - _planService, - _profile, - _trackSnapshotService, - _logging, - FormOptions, - CopyPreviousTrackSettings, - OnFileSettingsSaved, - () => w.Close()); - w.ShowDialog(); + return true; } private bool CanOpenTrackSettingsFromKeyboard(object? parameter) @@ -641,8 +742,13 @@ public sealed class ConversionViewModel : INotifyPropertyChanged var item = ResolveItemForTrackSettings(parameter); return item is not null - && item.MediaAnalysis is not null - && item.Status is not (ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing); + && item.Status is not ( + ConversionQueueStatus.Analyzing + or ConversionQueueStatus.Running + or ConversionQueueStatus.Copying + or ConversionQueueStatus.Replacing) + && !string.IsNullOrWhiteSpace(item.FullPath) + && File.Exists(item.FullPath); } private void ExecuteOpenTrackSettingsFromKeyboard(object? parameter) @@ -654,7 +760,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged } _logging.Debug("Открыты настройки дорожек через F2", "conversion.keyboard"); - ExecuteOpenFileSettings(item); + _ = ExecuteOpenFileSettingsAsync(item); } private ConversionQueueItem? ResolveItemForTrackSettings(object? parameter) @@ -689,7 +795,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing)); } - private void ExecuteOpenBulkFileSettings(object? parameter) + private async Task ExecuteOpenBulkFileSettingsAsync(object? parameter) { var selected = ResolveBulkSelection(parameter); if (selected.Count < 2) @@ -697,6 +803,12 @@ public sealed class ConversionViewModel : INotifyPropertyChanged return; } + if (!await EnsureMediaFreshForTrackEditorsAsync(selected, CancellationToken.None).ConfigureAwait(true)) + { + ShowToast("Не удалось обновить сведения о файлах (ffprobe).", ToastKind.Warning); + return; + } + _logging.Info($"массовая настройка: выбрано строк {selected.Count}", "conversion.bulk"); var analysis = _bulkTrackSettingsService.Analyze(selected); if (!analysis.HasMajority || analysis.MajorityItems.Count < 2)