Allow track settings for completed tasks: re-probe file, keep Done until save; reset to queue on changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emby Toolbox 2026-05-12 21:48:52 +05:00
parent 3459204e6f
commit f5c6cc7438
2 changed files with 253 additions and 29 deletions

View File

@ -1,4 +1,7 @@
using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EmbyToolbox.Models; using EmbyToolbox.Models;
namespace EmbyToolbox.Services; namespace EmbyToolbox.Services;
@ -6,6 +9,9 @@ namespace EmbyToolbox.Services;
/// <summary>Ход пакетного анализа очереди (ffprobe) для IProgress и UI.</summary> /// <summary>Ход пакетного анализа очереди (ffprobe) для IProgress и UI.</summary>
public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount); public readonly record struct QueueAnalysisProgress(int Processed, int Total, int ErrorCount);
/// <summary>Поля завершённой задачи, которые восстанавливаются после повторного ffprobe (форма дорожек).</summary>
public readonly record struct DoneCompletionState(int Progress, string? LastRunId, bool IsProcessed, bool ProcessedInCurrentRun);
/// <summary> /// <summary>
/// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по <see cref="CancellationToken"/>, /// Асинхронный батч ffprobe: ограниченный параллелизм, отмена по <see cref="CancellationToken"/>,
/// обновление строки через <paramref name="uiInvoke"/> (UI). /// обновление строки через <paramref name="uiInvoke"/> (UI).
@ -33,6 +39,112 @@ public sealed class QueueAnalysisService
_profile = profile; _profile = profile;
} }
/// <summary>
/// Повторный анализ текущего файла на диске (актуально после «Готово» или при отсутствии MediaAnalysis).
/// Пересобирает дорожки и план; если передан <paramref name="preserveCompletion"/> — статус остаётся «Готово».
/// </summary>
public async Task<bool> RefreshItemMediaForTrackEditorAsync(
ConversionQueueItem item,
bool autoRemoveForeignTracks,
bool disableSubtitleDefault,
DoneCompletionState? preserveCompletion,
Func<Action, Task> 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<SidecarFile>(), Array.Empty<ExternalAudioFile>());
}
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<int> RunAsync( public async Task<int> RunAsync(
IReadOnlyList<ConversionQueueItem> items, IReadOnlyList<ConversionQueueItem> items,
Func<ConversionQueueItem, bool> isStillInQueue, Func<ConversionQueueItem, bool> isStillInQueue,

View File

@ -5,6 +5,8 @@ using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows; using System.Windows;
using System.Windows.Data; using System.Windows.Data;
using System.Windows.Input; using System.Windows.Input;
@ -107,9 +109,13 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
PlayFileCommand = new RelayCommand(ExecutePlayFile, CanPlayFile); PlayFileCommand = new RelayCommand(ExecutePlayFile, CanPlayFile);
ClearQueueCommand = new RelayCommand(ExecuteClearQueue, () => QueueTasks.Count > 0 && !IsExecutionRunning); ClearQueueCommand = new RelayCommand(ExecuteClearQueue, () => QueueTasks.Count > 0 && !IsExecutionRunning);
ClearCompletedFromQueueCommand = new RelayCommand(ExecuteClearCompletedFromQueue, CanClearCompletedFromQueue); 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); OpenTrackSettingsCommand = new RelayCommand(ExecuteOpenTrackSettingsFromKeyboard, CanOpenTrackSettingsFromKeyboard);
OpenBulkFileConversionSettingsCommand = new RelayCommand(ExecuteOpenBulkFileSettings, CanOpenBulkFileSettings); OpenBulkFileConversionSettingsCommand = new RelayCommand(
p => { _ = ExecuteOpenBulkFileSettingsAsync(p); },
CanOpenBulkFileSettings);
StartProcessingCommand = new RelayCommand(ExecuteStartProcessing, () => !IsExecutionRunning); StartProcessingCommand = new RelayCommand(ExecuteStartProcessing, () => !IsExecutionRunning);
StopProcessingCommand = new RelayCommand(ExecuteStopProcessing, () => IsExecutionRunning); StopProcessingCommand = new RelayCommand(ExecuteStopProcessing, () => IsExecutionRunning);
CopyQueueItemErrorCommand = new RelayCommand(ExecuteCopyQueueItemError, CanCopyQueueItemError); 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);
}
}
/// <summary>
/// Перед формой дорожек: ffprobe для «Готово» и строк без MediaAnalysis.
/// </summary>
private async Task<bool> EnsureMediaFreshForTrackEditorsAsync(
IReadOnlyList<ConversionQueueItem> 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 true;
{
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();
} }
private bool CanOpenTrackSettingsFromKeyboard(object? parameter) private bool CanOpenTrackSettingsFromKeyboard(object? parameter)
@ -641,8 +742,13 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
var item = ResolveItemForTrackSettings(parameter); var item = ResolveItemForTrackSettings(parameter);
return item is not null return item is not null
&& item.MediaAnalysis is not null && item.Status is not (
&& item.Status is not (ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing); ConversionQueueStatus.Analyzing
or ConversionQueueStatus.Running
or ConversionQueueStatus.Copying
or ConversionQueueStatus.Replacing)
&& !string.IsNullOrWhiteSpace(item.FullPath)
&& File.Exists(item.FullPath);
} }
private void ExecuteOpenTrackSettingsFromKeyboard(object? parameter) private void ExecuteOpenTrackSettingsFromKeyboard(object? parameter)
@ -654,7 +760,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
} }
_logging.Debug("Открыты настройки дорожек через F2", "conversion.keyboard"); _logging.Debug("Открыты настройки дорожек через F2", "conversion.keyboard");
ExecuteOpenFileSettings(item); _ = ExecuteOpenFileSettingsAsync(item);
} }
private ConversionQueueItem? ResolveItemForTrackSettings(object? parameter) private ConversionQueueItem? ResolveItemForTrackSettings(object? parameter)
@ -689,7 +795,7 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing)); ConversionQueueStatus.Running or ConversionQueueStatus.Copying or ConversionQueueStatus.Replacing));
} }
private void ExecuteOpenBulkFileSettings(object? parameter) private async Task ExecuteOpenBulkFileSettingsAsync(object? parameter)
{ {
var selected = ResolveBulkSelection(parameter); var selected = ResolveBulkSelection(parameter);
if (selected.Count < 2) if (selected.Count < 2)
@ -697,6 +803,12 @@ public sealed class ConversionViewModel : INotifyPropertyChanged
return; return;
} }
if (!await EnsureMediaFreshForTrackEditorsAsync(selected, CancellationToken.None).ConfigureAwait(true))
{
ShowToast("Не удалось обновить сведения о файлах (ffprobe).", ToastKind.Warning);
return;
}
_logging.Info($"массовая настройка: выбрано строк {selected.Count}", "conversion.bulk"); _logging.Info($"массовая настройка: выбрано строк {selected.Count}", "conversion.bulk");
var analysis = _bulkTrackSettingsService.Analyze(selected); var analysis = _bulkTrackSettingsService.Analyze(selected);
if (!analysis.HasMajority || analysis.MajorityItems.Count < 2) if (!analysis.HasMajority || analysis.MajorityItems.Count < 2)