emby-toolbox/EmbyToolbox/ViewModels/TrackExtractionViewModel.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

833 lines
27 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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