833 lines
27 KiB
C#
833 lines
27 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.Collections.Specialized;
|
||
using System.ComponentModel;
|
||
using System.IO;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Windows;
|
||
using System.Windows.Threading;
|
||
using EmbyToolbox.Models;
|
||
using EmbyToolbox.Services;
|
||
using Microsoft.Win32;
|
||
|
||
namespace EmbyToolbox.ViewModels;
|
||
|
||
public sealed class TrackExtractionViewModel : INotifyPropertyChanged
|
||
{
|
||
private readonly LoggingService _logging;
|
||
private readonly TrackExtractionService _service;
|
||
private readonly RecentPathService _recentPaths;
|
||
private readonly ExtractCommandBuilder _cmdBuilder = new();
|
||
private readonly Dispatcher _dispatcher;
|
||
private readonly SemaphoreSlim _analyzeGate = new(1, 1);
|
||
|
||
private CancellationTokenSource? _operationCts;
|
||
private bool _isAnalyzing;
|
||
private bool _isExtracting;
|
||
private double _overallProgressPercent;
|
||
private string _executionPhaseCaption = string.Empty;
|
||
private TrackExtractionRunOutcome _lastRunOutcome = TrackExtractionRunOutcome.None;
|
||
private TrackExtractionQueueItem? _selectedItem;
|
||
private bool _isDropHighlight;
|
||
private string _destinationFolderPath = string.Empty;
|
||
|
||
public TrackExtractionViewModel(LoggingService logging, TrackExtractionService service, RecentPathService recentPaths)
|
||
{
|
||
_logging = logging;
|
||
_service = service;
|
||
_recentPaths = recentPaths;
|
||
_dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
|
||
|
||
Items.CollectionChanged += OnItemsCollectionChanged;
|
||
|
||
AddFilesCommand = new RelayCommand(ExecuteAddFiles, () => !IsBusy);
|
||
AddDirectoryCommand = new RelayCommand(ExecuteAddDirectory, () => !IsBusy);
|
||
ChooseDestinationFolderCommand = new RelayCommand(ExecuteChooseDestinationFolder, () => !IsBusy);
|
||
StartCommand = new RelayCommand(async () => await ExecuteStartAsync(), CanStart);
|
||
StopCommand = new RelayCommand(ExecuteStop, () => IsBusy);
|
||
ClearCommand = new RelayCommand(ExecuteClear, () => !IsBusy && Items.Count > 0);
|
||
|
||
DestinationFolderPath = _recentPaths.GetNormalizedRememberedFolderPath(RecentPathScenario.TrackExtractDestination)
|
||
?? string.Empty;
|
||
}
|
||
|
||
public ObservableCollection<TrackExtractionQueueItem> Items { get; } = new();
|
||
|
||
public RelayCommand AddFilesCommand { get; }
|
||
public RelayCommand AddDirectoryCommand { get; }
|
||
public RelayCommand ChooseDestinationFolderCommand { get; }
|
||
public RelayCommand StartCommand { get; }
|
||
public RelayCommand StopCommand { get; }
|
||
public RelayCommand ClearCommand { get; }
|
||
|
||
public string DestinationFolderPath
|
||
{
|
||
get => _destinationFolderPath;
|
||
set
|
||
{
|
||
if (_destinationFolderPath == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_destinationFolderPath = value;
|
||
OnPropertyChanged();
|
||
RaiseCommandStates();
|
||
}
|
||
}
|
||
|
||
public TrackExtractionQueueItem? SelectedItem
|
||
{
|
||
get => _selectedItem;
|
||
set
|
||
{
|
||
if (ReferenceEquals(_selectedItem, value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_selectedItem = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public bool IsDropHighlight
|
||
{
|
||
get => _isDropHighlight;
|
||
internal set
|
||
{
|
||
if (_isDropHighlight == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isDropHighlight = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public bool IsAnalyzingFiles
|
||
{
|
||
get => _isAnalyzing;
|
||
private set
|
||
{
|
||
if (_isAnalyzing == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isAnalyzing = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(IsBusy));
|
||
RaiseCommandStates();
|
||
NotifyLongOperationHost();
|
||
}
|
||
}
|
||
|
||
public bool IsExtracting
|
||
{
|
||
get => _isExtracting;
|
||
private set
|
||
{
|
||
if (_isExtracting == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isExtracting = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(IsBusy));
|
||
RaiseCommandStates();
|
||
NotifyLongOperationHost();
|
||
}
|
||
}
|
||
|
||
public bool IsBusy => IsAnalyzingFiles || IsExtracting;
|
||
|
||
public double OverallProgressPercent
|
||
{
|
||
get => _overallProgressPercent;
|
||
private set
|
||
{
|
||
if (Math.Abs(_overallProgressPercent - value) < 0.0001)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_overallProgressPercent = value;
|
||
OnPropertyChanged();
|
||
NotifyLongOperationHost();
|
||
}
|
||
}
|
||
|
||
public string ExecutionPhaseCaption
|
||
{
|
||
get => _executionPhaseCaption;
|
||
private set
|
||
{
|
||
if (_executionPhaseCaption == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_executionPhaseCaption = value;
|
||
OnPropertyChanged();
|
||
NotifyLongOperationHost();
|
||
}
|
||
}
|
||
|
||
public TrackExtractionRunOutcome LastRunOutcome
|
||
{
|
||
get => _lastRunOutcome;
|
||
private set
|
||
{
|
||
if (_lastRunOutcome == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_lastRunOutcome = value;
|
||
OnPropertyChanged();
|
||
NotifyLongOperationHost();
|
||
}
|
||
}
|
||
|
||
public event PropertyChangedEventHandler? PropertyChanged;
|
||
|
||
public void ApplyDroppedPaths(IReadOnlyList<string> rawPaths)
|
||
{
|
||
if (IsBusy || rawPaths is null || rawPaths.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
IsDropHighlight = false;
|
||
var discovered = new List<string>();
|
||
foreach (var raw in rawPaths)
|
||
{
|
||
try
|
||
{
|
||
var full = Path.GetFullPath(raw);
|
||
if (File.Exists(full))
|
||
{
|
||
if (TrackExtractionFormats.IsSupportedPath(full))
|
||
{
|
||
discovered.Add(full);
|
||
}
|
||
else
|
||
{
|
||
_logging.Warning($"извлечение дорожек (drop): пропуск неподдерживаемого файла: {full}", "tracks.extract");
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (Directory.Exists(full))
|
||
{
|
||
discovered.AddRange(TrackExtractionFormats.EnumerateMediaFilesRecursive(full));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logging.Warning($"извлечение дорожек (drop): не удалось обработать «{raw}»: {ex.Message}", "tracks.extract");
|
||
}
|
||
}
|
||
|
||
AddDiscoveredFiles(discovered);
|
||
}
|
||
|
||
private void ExecuteAddFiles()
|
||
{
|
||
var dialog = new OpenFileDialog
|
||
{
|
||
Title = "Добавить файлы",
|
||
Filter = TrackExtractionFormats.BuildOpenFileFilter(),
|
||
Multiselect = true,
|
||
};
|
||
|
||
if (dialog.ShowDialog() != true || dialog.FileNames.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
AddDiscoveredFiles(dialog.FileNames);
|
||
}
|
||
|
||
private void ExecuteAddDirectory()
|
||
{
|
||
var dlg = new OpenFolderDialog { Title = "Добавить каталог с видео" };
|
||
if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.FolderName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var list = TrackExtractionFormats.EnumerateMediaFilesRecursive(dlg.FolderName).ToList();
|
||
if (list.Count == 0)
|
||
{
|
||
_logging.Warning($"извлечение дорожек: в каталоге не найдено .mkv/.mp4: {dlg.FolderName}", "tracks.extract");
|
||
return;
|
||
}
|
||
|
||
AddDiscoveredFiles(list);
|
||
}
|
||
|
||
private void ExecuteChooseDestinationFolder()
|
||
{
|
||
var extra = string.IsNullOrWhiteSpace(DestinationFolderPath) ? null : DestinationFolderPath.Trim();
|
||
var dialog = new OpenFolderDialog
|
||
{
|
||
Title = "Папка назначения (будет создан каталог extract)",
|
||
InitialDirectory = _recentPaths.GetInitialDirectory(
|
||
RecentPathScenario.TrackExtractDestination,
|
||
extraFolderFallbackBeforeDefault: extra),
|
||
};
|
||
|
||
if (dialog.ShowDialog() != true || string.IsNullOrWhiteSpace(dialog.FolderName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
DestinationFolderPath = Path.GetFullPath(dialog.FolderName.Trim());
|
||
}
|
||
catch
|
||
{
|
||
DestinationFolderPath = dialog.FolderName.Trim();
|
||
}
|
||
|
||
_recentPaths.RememberChosenFolder(RecentPathScenario.TrackExtractDestination, DestinationFolderPath);
|
||
_logging.Info($"папка назначения извлечения: {DestinationFolderPath}", "tracks.extract");
|
||
}
|
||
|
||
private void AddDiscoveredFiles(IReadOnlyList<string> paths)
|
||
{
|
||
if (paths.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var existing = new HashSet<string>(Items.Select(i => i.FullPath), StringComparer.OrdinalIgnoreCase);
|
||
var newlyAdded = new List<TrackExtractionQueueItem>();
|
||
foreach (var p in paths.Distinct(StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
if (!File.Exists(p) || !TrackExtractionFormats.IsSupportedPath(p))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!existing.Add(p))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var item = new TrackExtractionQueueItem(p);
|
||
Items.Add(item);
|
||
newlyAdded.Add(item);
|
||
_logging.Info($"файл добавлен в извлечение дорожек: {Path.GetFileName(p)}", "tracks.extract");
|
||
}
|
||
|
||
RenumberRows();
|
||
if (newlyAdded.Count > 0)
|
||
{
|
||
_ = RunAnalyzeGateAsync(newlyAdded);
|
||
}
|
||
|
||
RaiseCommandStates();
|
||
}
|
||
|
||
private async Task RunAnalyzeGateAsync(List<TrackExtractionQueueItem> batch)
|
||
{
|
||
await _analyzeGate.WaitAsync();
|
||
try
|
||
{
|
||
_operationCts = new CancellationTokenSource();
|
||
var token = _operationCts.Token;
|
||
IsAnalyzingFiles = true;
|
||
LastRunOutcome = TrackExtractionRunOutcome.None;
|
||
OverallProgressPercent = 0;
|
||
ExecutionPhaseCaption = "Извлечение дорожек — анализ";
|
||
var done = 0;
|
||
foreach (var item in batch)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
if (!Items.Contains(item))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
await AnalyzeOneItemAsync(item, token);
|
||
done++;
|
||
OverallProgressPercent = batch.Count > 0 ? 100.0 * done / batch.Count : 100;
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
foreach (var item in batch.Where(i => Items.Contains(i)))
|
||
{
|
||
if (item.Status == TrackExtractionStatuses.Analyzing)
|
||
{
|
||
item.Status = TrackExtractionStatuses.Cancelled;
|
||
item.Message = "Остановлено пользователем.";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logging.Error($"извлечение дорожек: сбой пакета анализа: {ex.Message}", "tracks.extract", ex);
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
foreach (var item in batch.Where(i => Items.Contains(i) && i.Status == TrackExtractionStatuses.Analyzing))
|
||
{
|
||
item.ApplyAnalysisError("Сбой анализа.");
|
||
}
|
||
});
|
||
}
|
||
finally
|
||
{
|
||
IsAnalyzingFiles = false;
|
||
_operationCts?.Dispose();
|
||
_operationCts = null;
|
||
OverallProgressPercent = 0;
|
||
ExecutionPhaseCaption = string.Empty;
|
||
_analyzeGate.Release();
|
||
}
|
||
}
|
||
|
||
private async Task AnalyzeOneItemAsync(TrackExtractionQueueItem item, CancellationToken token)
|
||
{
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
item.Status = TrackExtractionStatuses.Analyzing;
|
||
item.Message = string.Empty;
|
||
});
|
||
|
||
try
|
||
{
|
||
var media = await _service.AnalyzeMediaAsync(item.FullPath, _logging, token).ConfigureAwait(false);
|
||
if (token.IsCancellationRequested)
|
||
{
|
||
return;
|
||
}
|
||
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
if (media is null)
|
||
{
|
||
item.ApplyAnalysisError("Не удалось разобрать вывод ffprobe.");
|
||
}
|
||
else
|
||
{
|
||
item.ApplyAnalysisOk(media);
|
||
}
|
||
});
|
||
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
if (item.MediaAnalysis is not null)
|
||
{
|
||
_logging.Info($"анализ ffprobe завершён для «{item.FileName}»", "tracks.extract");
|
||
}
|
||
});
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await _dispatcher.InvokeAsync(() => item.ApplyAnalysisError(ex.Message));
|
||
_logging.Error($"ошибка анализа «{item.FileName}»: {ex.Message}", "tracks.extract");
|
||
}
|
||
}
|
||
|
||
private static bool TryNormalizeDestinationTrimmed(string? raw, out string normalizedTrimmed)
|
||
{
|
||
normalizedTrimmed = string.Empty;
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
normalizedTrimmed = Path.GetFullPath(raw.Trim());
|
||
return true;
|
||
}
|
||
catch (ArgumentException)
|
||
{
|
||
return false;
|
||
}
|
||
catch (NotSupportedException)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool CanStart()
|
||
{
|
||
var destOk = TryNormalizeDestinationTrimmed(DestinationFolderPath, out _);
|
||
return !IsBusy && destOk &&
|
||
Items.Any(i => i.Status == TrackExtractionStatuses.Ready);
|
||
}
|
||
|
||
private async Task ExecuteStartAsync()
|
||
{
|
||
var ready = Items.Where(i => i.Status == TrackExtractionStatuses.Ready && i.MediaAnalysis is not null).ToList();
|
||
if (ready.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!TryNormalizeDestinationTrimmed(DestinationFolderPath, out var destTrimmed))
|
||
{
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
foreach (var it in ready)
|
||
{
|
||
it.Status = TrackExtractionStatuses.Error;
|
||
it.Message = "Укажите корректную папку назначения.";
|
||
it.ProgressPercent = 100;
|
||
}
|
||
});
|
||
|
||
LastRunOutcome = TrackExtractionRunOutcome.Error;
|
||
RaiseCommandStates();
|
||
return;
|
||
}
|
||
|
||
_operationCts = new CancellationTokenSource();
|
||
var token = _operationCts.Token;
|
||
IsExtracting = true;
|
||
LastRunOutcome = TrackExtractionRunOutcome.None;
|
||
var totalTracks = ready.Sum(i => Math.Max(i.TotalTracksToExtract, 0));
|
||
if (totalTracks <= 0)
|
||
{
|
||
totalTracks = ready.Count;
|
||
}
|
||
|
||
var doneTracks = 0;
|
||
var runHadErrors = false;
|
||
var cancelled = false;
|
||
ExecutionPhaseCaption = "Извлечение дорожек — извлечение";
|
||
|
||
try
|
||
{
|
||
var extractRoot = _service.PrepareExtractLayout(destTrimmed);
|
||
if (extractRoot is null)
|
||
{
|
||
runHadErrors = true;
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
foreach (var it in ready)
|
||
{
|
||
it.Status = TrackExtractionStatuses.Error;
|
||
it.Message =
|
||
"Не удалось создать каталог extract. Проверьте папку назначения и доступ.";
|
||
it.ProgressPercent = 100;
|
||
}
|
||
});
|
||
|
||
_logging.Error("извлечение дорожек: не удалось создать extract в папке назначения", "tracks.extract");
|
||
}
|
||
else
|
||
{
|
||
_logging.Info($"подготовлен каталог извлечения: {extractRoot}", "tracks.extract");
|
||
|
||
var audioDir = Path.Combine(extractRoot, "audio");
|
||
var subsDir = Path.Combine(extractRoot, "subtitles");
|
||
var attDir = Path.Combine(extractRoot, "attachments");
|
||
|
||
foreach (var item in ready)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
item.Status = TrackExtractionStatuses.Working;
|
||
item.ProgressPercent = 0;
|
||
item.Message = string.Empty;
|
||
});
|
||
|
||
var media = item.MediaAnalysis!;
|
||
var totalInFile = Math.Max(item.TotalTracksToExtract, 1);
|
||
var doneInFile = 0;
|
||
var fileHadErrors = false;
|
||
|
||
if (item.TotalTracksToExtract <= 0)
|
||
{
|
||
doneTracks++;
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
item.ProgressPercent = 100;
|
||
item.Status = TrackExtractionStatuses.Done;
|
||
item.Message = "Дорожек не найдено.";
|
||
OverallProgressPercent = 100.0 * doneTracks / Math.Max(totalTracks, 1);
|
||
});
|
||
continue;
|
||
}
|
||
|
||
var stem = ExtractCommandBuilder.SanitizeSourceFileStem(Path.GetFileNameWithoutExtension(item.FullPath));
|
||
var a = 0;
|
||
foreach (var s in media.AudioStreams)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
a++;
|
||
var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, a);
|
||
var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(audioDir, desired);
|
||
var dest = Path.Combine(audioDir, allocated);
|
||
if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false))
|
||
{
|
||
fileHadErrors = true;
|
||
runHadErrors = true;
|
||
}
|
||
|
||
doneInFile++;
|
||
doneTracks++;
|
||
UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks);
|
||
}
|
||
|
||
var su = 0;
|
||
foreach (var s in media.SubtitleStreams)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
su++;
|
||
var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, su);
|
||
var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(subsDir, desired);
|
||
var dest = Path.Combine(subsDir, allocated);
|
||
if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false))
|
||
{
|
||
fileHadErrors = true;
|
||
runHadErrors = true;
|
||
}
|
||
|
||
doneInFile++;
|
||
doneTracks++;
|
||
UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks);
|
||
}
|
||
|
||
var att = 0;
|
||
foreach (var s in media.AllStreams.Where(x => x.Kind == MediaStreamKind.Attachment))
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
att++;
|
||
var desired = _cmdBuilder.ResolveOutputBaseFileName(stem, s, att);
|
||
var allocated = TrackExtractOutputPaths.AllocateUniqueFilename(attDir, desired);
|
||
var dest = Path.Combine(attDir, allocated);
|
||
if (!await ExtractStreamAsync(item, s, dest, token).ConfigureAwait(false))
|
||
{
|
||
fileHadErrors = true;
|
||
runHadErrors = true;
|
||
}
|
||
|
||
doneInFile++;
|
||
doneTracks++;
|
||
UpdateRowAndOverall(item, doneInFile, totalInFile, doneTracks, totalTracks);
|
||
}
|
||
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
if (item.Status == TrackExtractionStatuses.Cancelled)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (fileHadErrors)
|
||
{
|
||
item.Status = TrackExtractionStatuses.Error;
|
||
item.ProgressPercent = 100;
|
||
}
|
||
else
|
||
{
|
||
item.Status = TrackExtractionStatuses.Done;
|
||
item.Message = "Готово.";
|
||
item.ProgressPercent = 100;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
cancelled = true;
|
||
LastRunOutcome = TrackExtractionRunOutcome.Cancelled;
|
||
foreach (var item in Items.Where(i => i.Status == TrackExtractionStatuses.Working))
|
||
{
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
item.Status = TrackExtractionStatuses.Cancelled;
|
||
item.Message = "Остановлено пользователем.";
|
||
});
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
runHadErrors = true;
|
||
_logging.Error($"извлечение дорожек: неперехваченная ошибка: {ex.Message}", "tracks.extract", ex);
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
foreach (var item in Items.Where(i => i.Status == TrackExtractionStatuses.Working))
|
||
{
|
||
item.Status = TrackExtractionStatuses.Error;
|
||
item.Message = ex.Message.Length > 200 ? ex.Message[..200] + "…" : ex.Message;
|
||
}
|
||
});
|
||
}
|
||
finally
|
||
{
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
IsExtracting = false;
|
||
_operationCts?.Dispose();
|
||
_operationCts = null;
|
||
OverallProgressPercent = 0;
|
||
ExecutionPhaseCaption = string.Empty;
|
||
|
||
if (!cancelled)
|
||
{
|
||
LastRunOutcome = runHadErrors
|
||
? TrackExtractionRunOutcome.Error
|
||
: TrackExtractionRunOutcome.Success;
|
||
}
|
||
|
||
RaiseCommandStates();
|
||
});
|
||
}
|
||
}
|
||
|
||
private void UpdateRowAndOverall(
|
||
TrackExtractionQueueItem item,
|
||
int doneInFile,
|
||
int totalInFile,
|
||
int doneTracks,
|
||
int totalTracks)
|
||
{
|
||
var rowPct = 100.0 * doneInFile / totalInFile;
|
||
var overall = 100.0 * doneTracks / Math.Max(totalTracks, 1);
|
||
_dispatcher.InvokeAsync(() =>
|
||
{
|
||
item.ProgressPercent = rowPct;
|
||
OverallProgressPercent = overall;
|
||
});
|
||
}
|
||
|
||
private async Task<bool> ExtractStreamAsync(
|
||
TrackExtractionQueueItem item,
|
||
MediaStreamInfo stream,
|
||
string destinationPath,
|
||
CancellationToken token)
|
||
{
|
||
try
|
||
{
|
||
var dir = Path.GetDirectoryName(destinationPath);
|
||
if (!string.IsNullOrEmpty(dir))
|
||
{
|
||
Directory.CreateDirectory(dir);
|
||
}
|
||
|
||
var args = _cmdBuilder.BuildFfmpegArgumentList(item.FullPath, stream, destinationPath);
|
||
var (ok, err) = await _service.RunExtractProcessAsync(args, token).ConfigureAwait(false);
|
||
if (ok)
|
||
{
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
var shortName = Path.GetFileName(destinationPath);
|
||
item.Message = $"Извлечено: {shortName}";
|
||
});
|
||
_logging.Info($"извлечена дорожка [{stream.Kind}] #{stream.Index} → {destinationPath}", "tracks.extract");
|
||
return true;
|
||
}
|
||
|
||
var msg = string.IsNullOrWhiteSpace(err) ? $"ffmpeg ошибка дорожки {stream.Index}" : err.Trim();
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
item.Status = TrackExtractionStatuses.Error;
|
||
item.Message = msg.Length > 200 ? msg[..200] + "…" : msg;
|
||
});
|
||
_logging.Error($"ошибка извлечения дорожки #{stream.Index} из «{item.FileName}»: {msg}", "tracks.extract");
|
||
return false;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await _dispatcher.InvokeAsync(() =>
|
||
{
|
||
item.Status = TrackExtractionStatuses.Error;
|
||
item.Message = ex.Message;
|
||
});
|
||
_logging.Error($"ошибка извлечения «{item.FileName}»: {ex.Message}", "tracks.extract", ex);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void ExecuteStop()
|
||
{
|
||
try
|
||
{
|
||
_operationCts?.Cancel();
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
private void ExecuteClear()
|
||
{
|
||
if (IsBusy)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Items.Clear();
|
||
SelectedItem = null;
|
||
LastRunOutcome = TrackExtractionRunOutcome.None;
|
||
RenumberRows();
|
||
RaiseCommandStates();
|
||
}
|
||
|
||
private void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||
{
|
||
RenumberRows();
|
||
RaiseCommandStates();
|
||
}
|
||
|
||
private void RenumberRows()
|
||
{
|
||
var n = 1;
|
||
foreach (var i in Items)
|
||
{
|
||
i.RowNumber = n++;
|
||
}
|
||
}
|
||
|
||
private void RaiseCommandStates()
|
||
{
|
||
AddFilesCommand.RaiseCanExecuteChanged();
|
||
AddDirectoryCommand.RaiseCanExecuteChanged();
|
||
ChooseDestinationFolderCommand.RaiseCanExecuteChanged();
|
||
StartCommand.RaiseCanExecuteChanged();
|
||
StopCommand.RaiseCanExecuteChanged();
|
||
ClearCommand.RaiseCanExecuteChanged();
|
||
}
|
||
|
||
private void NotifyLongOperationHost()
|
||
{
|
||
if (_dispatcher.CheckAccess())
|
||
{
|
||
OnPropertyChanged(nameof(IsBusy));
|
||
}
|
||
else
|
||
{
|
||
_dispatcher.BeginInvoke(() => OnPropertyChanged(nameof(IsBusy)));
|
||
}
|
||
}
|
||
|
||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||
}
|