1502 lines
46 KiB
C#
1502 lines
46 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.Collections;
|
||
using System.ComponentModel;
|
||
using System.Diagnostics;
|
||
using System.Globalization;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Windows;
|
||
using System.Windows.Input;
|
||
using System.Windows.Threading;
|
||
using EmbyToolbox.Models;
|
||
using EmbyToolbox.Services;
|
||
|
||
namespace EmbyToolbox.ViewModels;
|
||
|
||
public sealed class FileConversionSettingsViewModel : INotifyPropertyChanged, ITrackPlanPreviewHost
|
||
{
|
||
private readonly ConversionQueueItem _item;
|
||
private readonly ConversionTaskOverride _draft;
|
||
private readonly ConversionPlanService _planner;
|
||
private readonly IProfileSettingsProvider _profile;
|
||
private readonly TrackSettingsSnapshotService _snapshotService;
|
||
private readonly LoggingService _logging;
|
||
private readonly Action _saveAndCloseAction;
|
||
private string _planPreview = string.Empty;
|
||
private string _container = string.Empty;
|
||
private string _video = string.Empty;
|
||
private string _pixel = string.Empty;
|
||
private string _resolution = string.Empty;
|
||
private string _fps = string.Empty;
|
||
private string _videoBitrateMode = VideoBitratePolicy.Auto;
|
||
private string _videoBitrateCustomMbps = string.Empty;
|
||
private string _currentFileVideoBitrate = "Текущее: неизвестно";
|
||
private string _bulkTrackType = "Все";
|
||
private TrackActionKind? _bulkActionValue;
|
||
private string? _bulkBitrateValue;
|
||
private bool _isSyncingBulkFromSelection;
|
||
private bool _isBulkBitrateEnabled;
|
||
private string? _fileContainer;
|
||
private string? _fileVideo;
|
||
private string? _filePixel;
|
||
private string? _fileResolution;
|
||
private string? _fileFps;
|
||
private bool _isAutoAppliedFromSnapshot;
|
||
private string _snapshotStatusText = string.Empty;
|
||
private string _toastMessage = string.Empty;
|
||
private bool _isToastVisible;
|
||
private ToastKind _toastKind;
|
||
private DispatcherTimer? _toastHideTimer;
|
||
|
||
private const double ToastDismissSecondsMin = 2.0;
|
||
private const double ToastDismissSecondsMax = 3.0;
|
||
|
||
/// <summary>Порог длины текста (символы): дольше — показ до <see cref="ToastDismissSecondsMax"/> с.</summary>
|
||
private const int ToastLongMessageCharThreshold = 56;
|
||
|
||
/// <remarks>Строки для toast (кратко и заметно); подробности — в статусе внизу.</remarks>
|
||
private const string SnapshotToastAppliedFull =
|
||
"Настройки предыдущего файла применены";
|
||
|
||
private const string SnapshotToastAppliedPartial =
|
||
"Частично применены настройки предыдущего файла";
|
||
|
||
/// <summary>Единый текст toast при любой неудаче автоприменения snapshot.</summary>
|
||
private const string SnapshotToastNotApplied =
|
||
"Настройки предыдущего файла не применены";
|
||
|
||
private const string SnapshotStatusStructureMismatch =
|
||
"Настройки предыдущего файла не применены: структура дорожек отличается";
|
||
|
||
public FileConversionSettingsViewModel(
|
||
ConversionQueueItem item,
|
||
ConversionPlanService planner,
|
||
IProfileSettingsProvider profile,
|
||
TrackSettingsSnapshotService snapshotService,
|
||
LoggingService logging,
|
||
ConversionFormOptions formOptions,
|
||
bool copyPreviousTrackSettings,
|
||
Action<ConversionQueueItem, bool> onSaveApplied,
|
||
Action onClose)
|
||
{
|
||
_item = item;
|
||
_planner = planner;
|
||
_profile = profile;
|
||
_snapshotService = snapshotService;
|
||
_logging = logging;
|
||
_draft = item.TaskOverride.Clone();
|
||
_saveAndCloseAction = () =>
|
||
{
|
||
if (_item.MediaAnalysis is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!ValidateBeforeSave(showMessage: true))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var hasChangesFromCurrent = !OverridesEquivalent(_draft, _item.TaskOverride);
|
||
var isManual = IsDraftDifferentFromAutoPlan();
|
||
_item.TaskOverride.CopyFrom(_draft);
|
||
var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||
var plan = _planner.Build(_item.MediaAnalysis, _item.Sidecars, prof, _item.TaskOverride, _item.ExternalAudioFiles);
|
||
_item.IsManuallyEdited = isManual;
|
||
_item.SetPlan(plan);
|
||
SaveCurrentSnapshot();
|
||
onSaveApplied(_item, hasChangesFromCurrent);
|
||
onClose();
|
||
};
|
||
FormOptions = formOptions;
|
||
FilePath = item.FullPath;
|
||
ProfileName = item.Profile;
|
||
FillFileActuals();
|
||
_container = _draft.TargetContainer;
|
||
_video = _draft.TargetVideo;
|
||
_pixel = _draft.TargetPixelFormat;
|
||
_resolution = _draft.TargetResolution;
|
||
_fps = _draft.TargetFps;
|
||
_videoBitrateMode = string.IsNullOrWhiteSpace(_draft.TargetVideoBitrateMode)
|
||
? VideoBitratePolicy.Auto
|
||
: _draft.TargetVideoBitrateMode;
|
||
_videoBitrateCustomMbps = _draft.TargetVideoBitrateMbps?.ToString("0.###", CultureInfo.InvariantCulture) ?? string.Empty;
|
||
_planPreview = item.LastPlan?.ShortSummary ?? item.PlanSummary;
|
||
RebuildRowsFromDraft();
|
||
if (copyPreviousTrackSettings)
|
||
{
|
||
TryApplyPreviousSnapshot();
|
||
}
|
||
else
|
||
{
|
||
OpenWithAutoPlanOnly();
|
||
}
|
||
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
|
||
SaveCommand = new RelayCommand(() => _saveAndCloseAction(), () => _item.MediaAnalysis is not null);
|
||
SaveAndCloseCommand = new RelayCommand(
|
||
ExecuteSaveAndCloseFromHotkey,
|
||
CanExecuteSaveAndCloseFromHotkey);
|
||
|
||
CancelCommand = new RelayCommand(onClose);
|
||
UndoAutoApplyCommand = new RelayCommand(ExecuteUndoAutoApply, () => IsAutoAppliedFromSnapshot);
|
||
RemoveForeignTracksCommand = new RelayCommand(RemoveForeignTracks);
|
||
MarkForeignTracksForRemovalCommand = RemoveForeignTracksCommand;
|
||
SetSelectedTracksRemoveCommand = new RelayCommand(SetSelectedTracksRemove, CanSetSelectedTracksRemove);
|
||
OnSelectionChangedCommand = new RelayCommand(OnSelectionChanged);
|
||
ContextSetActionCommand = new RelayCommand(ApplyContextAction, CanApplyContextAction);
|
||
ContextSetBitrateCommand = new RelayCommand(ApplyContextBitrate, CanApplyContextBitrate);
|
||
PlayFileCommand = new RelayCommand(ExecutePlayFile, CanExecutePlayFile);
|
||
CloseToastCommand = new RelayCommand(ExecuteCloseToast);
|
||
}
|
||
|
||
public string BulkTrackType
|
||
{
|
||
get => _bulkTrackType;
|
||
set
|
||
{
|
||
if (_bulkTrackType == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_bulkTrackType = value;
|
||
OnPropertyChanged();
|
||
SyncBulkControlsFromSelection();
|
||
}
|
||
}
|
||
|
||
public TrackActionKind? BulkActionValue
|
||
{
|
||
get => _bulkActionValue;
|
||
set
|
||
{
|
||
if (_bulkActionValue == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_bulkActionValue = value;
|
||
OnPropertyChanged();
|
||
if (!_isSyncingBulkFromSelection && value is { } action)
|
||
{
|
||
ApplyBulkActionOnChange(action);
|
||
}
|
||
}
|
||
}
|
||
|
||
public string? BulkBitrateValue
|
||
{
|
||
get => _bulkBitrateValue;
|
||
set
|
||
{
|
||
if (_bulkBitrateValue == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_bulkBitrateValue = value;
|
||
OnPropertyChanged();
|
||
if (!_isSyncingBulkFromSelection && !string.IsNullOrWhiteSpace(value))
|
||
{
|
||
ApplyBulkBitrateOnChange(value);
|
||
}
|
||
}
|
||
}
|
||
|
||
public ConversionFormOptions FormOptions { get; }
|
||
public string FilePath { get; }
|
||
public string ProfileName { get; }
|
||
public ObservableCollection<TrackSettingsRowViewModel> TrackRows { get; } = new();
|
||
public ObservableCollection<TrackSettingsRowViewModel> SelectedTracks { get; } = new();
|
||
public string? CurrentFileContainer => _fileContainer;
|
||
public string? CurrentFileVideo => _fileVideo;
|
||
public string? CurrentFilePixel => _filePixel;
|
||
public string? CurrentFileResolution => _fileResolution;
|
||
public string? CurrentFileFps => _fileFps;
|
||
public string CurrentFileVideoBitrate => _currentFileVideoBitrate;
|
||
public IReadOnlyList<string> VideoBitrateOptions => FormOptions.VideoBitrateModeOptions;
|
||
public bool IsVideoBitrateCustomVisible => string.Equals(TargetVideoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal);
|
||
public ICommand SaveCommand { get; }
|
||
public RelayCommand SaveAndCloseCommand { get; }
|
||
public ICommand CancelCommand { get; }
|
||
public RelayCommand UndoAutoApplyCommand { get; }
|
||
public ICommand RemoveForeignTracksCommand { get; }
|
||
public ICommand MarkForeignTracksForRemovalCommand { get; }
|
||
public RelayCommand SetSelectedTracksRemoveCommand { get; }
|
||
public ICommand OnSelectionChangedCommand { get; }
|
||
public RelayCommand ContextSetActionCommand { get; }
|
||
public RelayCommand ContextSetBitrateCommand { get; }
|
||
public RelayCommand PlayFileCommand { get; }
|
||
|
||
public RelayCommand CloseToastCommand { get; }
|
||
|
||
/// <summary>Подсказка для кнопки воспроизведения (файл существует / нет).</summary>
|
||
public string PlayFileToolTip =>
|
||
CanExecutePlayFile(FilePath) ? "Воспроизвести файл" : "Файл не найден";
|
||
|
||
public IReadOnlyList<string> BulkTrackTypeOptions { get; } = ["Все", "Видео", "Аудио", "Субтитры", "Attachments"];
|
||
public IReadOnlyList<TrackActionKind> BulkActionOptions { get; } = [TrackActionKind.Keep, TrackActionKind.Convert, TrackActionKind.Remove, TrackActionKind.Add];
|
||
public IReadOnlyList<string> BulkBitrateOptions => FormOptions.AudioBitrateKbps;
|
||
public bool IsBulkBitrateEnabled
|
||
{
|
||
get => _isBulkBitrateEnabled;
|
||
private set
|
||
{
|
||
if (_isBulkBitrateEnabled == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isBulkBitrateEnabled = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public bool IsAutoAppliedFromSnapshot
|
||
{
|
||
get => _isAutoAppliedFromSnapshot;
|
||
private set
|
||
{
|
||
if (_isAutoAppliedFromSnapshot == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isAutoAppliedFromSnapshot = value;
|
||
OnPropertyChanged();
|
||
UndoAutoApplyCommand?.RaiseCanExecuteChanged();
|
||
}
|
||
}
|
||
|
||
public string SnapshotStatusText
|
||
{
|
||
get => _snapshotStatusText;
|
||
private set
|
||
{
|
||
if (_snapshotStatusText == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_snapshotStatusText = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public string ToastMessage
|
||
{
|
||
get => _toastMessage;
|
||
private set
|
||
{
|
||
if (_toastMessage == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_toastMessage = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public bool IsToastVisible
|
||
{
|
||
get => _isToastVisible;
|
||
private set
|
||
{
|
||
if (_isToastVisible == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isToastVisible = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public ToastKind ToastKind
|
||
{
|
||
get => _toastKind;
|
||
private set
|
||
{
|
||
if (_toastKind == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_toastKind = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(ToastIconGlyph));
|
||
}
|
||
}
|
||
|
||
public string ToastIconGlyph =>
|
||
ToastKind switch
|
||
{
|
||
ToastKind.Success => "\uE73E",
|
||
ToastKind.Warning => "\uE814",
|
||
ToastKind.Error => "\uEA39",
|
||
_ => "\uE946"
|
||
};
|
||
|
||
public void ShowToast(string message, ToastKind kind)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(message))
|
||
{
|
||
return;
|
||
}
|
||
|
||
StopToastHideTimer();
|
||
|
||
var trimmed = message.Trim();
|
||
ToastMessage = trimmed;
|
||
ToastKind = kind;
|
||
IsToastVisible = true;
|
||
|
||
var sec = ResolveToastDismissSeconds(trimmed);
|
||
|
||
var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
|
||
_toastHideTimer = new DispatcherTimer(
|
||
TimeSpan.FromSeconds(sec),
|
||
DispatcherPriority.Background,
|
||
OnToastHideTick,
|
||
dispatcher);
|
||
}
|
||
|
||
private static double ResolveToastDismissSeconds(string message) =>
|
||
message.Length > ToastLongMessageCharThreshold
|
||
? ToastDismissSecondsMax
|
||
: ToastDismissSecondsMin +
|
||
(ToastDismissSecondsMax - ToastDismissSecondsMin)
|
||
* (message.Length / (double)ToastLongMessageCharThreshold);
|
||
|
||
private void OnToastHideTick(object? sender, EventArgs e)
|
||
{
|
||
HideToastInstant();
|
||
}
|
||
|
||
private void ExecuteCloseToast()
|
||
{
|
||
if (!IsToastVisible)
|
||
{
|
||
return;
|
||
}
|
||
|
||
HideToastInstant();
|
||
}
|
||
|
||
private void HideToastInstant()
|
||
{
|
||
StopToastHideTimer();
|
||
IsToastVisible = false;
|
||
ToastMessage = string.Empty;
|
||
}
|
||
|
||
private void StopToastHideTimer()
|
||
{
|
||
if (_toastHideTimer is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_toastHideTimer.Stop();
|
||
_toastHideTimer = null;
|
||
}
|
||
|
||
public string PlanPreview
|
||
{
|
||
get => _planPreview;
|
||
private set
|
||
{
|
||
if (_planPreview == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_planPreview = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public string TargetContainer
|
||
{
|
||
get => _container;
|
||
set
|
||
{
|
||
if (_container == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_container = value;
|
||
_draft.TargetContainer = value;
|
||
OnPropertyChanged();
|
||
foreach (var row in TrackRows)
|
||
{
|
||
row.RefreshSubtitleDetails(_container);
|
||
}
|
||
|
||
RecalculatePlanPreview();
|
||
}
|
||
}
|
||
|
||
public string TargetVideo
|
||
{
|
||
get => _video;
|
||
set
|
||
{
|
||
if (_video == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_video = value;
|
||
_draft.TargetVideo = value;
|
||
OnPropertyChanged();
|
||
RecalculatePlanPreview();
|
||
}
|
||
}
|
||
|
||
public string TargetPixelFormat
|
||
{
|
||
get => _pixel;
|
||
set
|
||
{
|
||
if (_pixel == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_pixel = value;
|
||
_draft.TargetPixelFormat = value;
|
||
OnPropertyChanged();
|
||
RecalculatePlanPreview();
|
||
}
|
||
}
|
||
|
||
public string TargetResolution
|
||
{
|
||
get => _resolution;
|
||
set
|
||
{
|
||
if (_resolution == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_resolution = value;
|
||
_draft.TargetResolution = value;
|
||
OnPropertyChanged();
|
||
RecalculatePlanPreview();
|
||
}
|
||
}
|
||
|
||
public string TargetFps
|
||
{
|
||
get => _fps;
|
||
set
|
||
{
|
||
if (_fps == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_fps = value;
|
||
_draft.TargetFps = value;
|
||
OnPropertyChanged();
|
||
RecalculatePlanPreview();
|
||
}
|
||
}
|
||
|
||
public string TargetVideoBitrateMode
|
||
{
|
||
get => _videoBitrateMode;
|
||
set
|
||
{
|
||
var next = string.IsNullOrWhiteSpace(value) ? VideoBitratePolicy.Auto : value.Trim();
|
||
if (_videoBitrateMode == next)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_videoBitrateMode = next;
|
||
_draft.TargetVideoBitrateMode = next;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(IsVideoBitrateCustomVisible));
|
||
RecalculatePlanPreview();
|
||
}
|
||
}
|
||
|
||
public string VideoBitrateCustomMbps
|
||
{
|
||
get => _videoBitrateCustomMbps;
|
||
set
|
||
{
|
||
if (_videoBitrateCustomMbps == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_videoBitrateCustomMbps = value;
|
||
OnPropertyChanged();
|
||
if (TryParseCustomVideoBitrate(value, out var mbps))
|
||
{
|
||
_draft.TargetVideoBitrateMbps = mbps;
|
||
}
|
||
else
|
||
{
|
||
_draft.TargetVideoBitrateMbps = null;
|
||
}
|
||
|
||
RecalculatePlanPreview();
|
||
}
|
||
}
|
||
|
||
public void RecalculatePlanPreview()
|
||
{
|
||
if (_item.MediaAnalysis is not { } m)
|
||
{
|
||
PlanPreview = "—";
|
||
return;
|
||
}
|
||
|
||
_draft.TargetContainer = _container;
|
||
_draft.TargetVideo = _video;
|
||
_draft.TargetPixelFormat = _pixel;
|
||
_draft.TargetResolution = _resolution;
|
||
_draft.TargetFps = _fps;
|
||
_draft.TargetVideoBitrateMode = _videoBitrateMode;
|
||
_draft.TargetVideoBitrateMbps = string.Equals(_videoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal)
|
||
&& TryParseCustomVideoBitrate(_videoBitrateCustomMbps, out var mbps)
|
||
? mbps
|
||
: null;
|
||
ValidateDefaultConflicts();
|
||
var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||
var plan = _planner.Build(m, _item.Sidecars, prof, _draft, _item.ExternalAudioFiles);
|
||
PlanPreview = plan.ShortSummary;
|
||
}
|
||
|
||
public void OnTrackDefaultEnabled(TrackSettingsRowViewModel row)
|
||
{
|
||
if (row.DataModel.StreamKind is not (MediaStreamKind.Audio or MediaStreamKind.Subtitle))
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var r in TrackRows)
|
||
{
|
||
if (ReferenceEquals(r, row))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (r.DataModel.StreamKind == row.DataModel.StreamKind)
|
||
{
|
||
r.Default = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
public void ValidateDefaultConflicts()
|
||
{
|
||
var audioDefaults = TrackRows
|
||
.Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio && r.Action != TrackActionKind.Remove && r.Default is true)
|
||
.ToList();
|
||
var subDefaults = TrackRows
|
||
.Where(r => r.DataModel.StreamKind == MediaStreamKind.Subtitle && r.Action != TrackActionKind.Remove && r.Default is true)
|
||
.ToList();
|
||
|
||
foreach (var r in TrackRows)
|
||
{
|
||
r.HasDefaultConflict = false;
|
||
}
|
||
|
||
if (audioDefaults.Count > 1)
|
||
{
|
||
foreach (var r in audioDefaults)
|
||
{
|
||
r.HasDefaultConflict = true;
|
||
}
|
||
}
|
||
|
||
if (subDefaults.Count > 1)
|
||
{
|
||
foreach (var r in subDefaults)
|
||
{
|
||
r.HasDefaultConflict = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
public event PropertyChangedEventHandler? PropertyChanged;
|
||
|
||
private bool CanExecuteSaveAndCloseFromHotkey()
|
||
{
|
||
if (_item.MediaAnalysis is null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (Keyboard.FocusedElement is System.Windows.Controls.ComboBox cb && cb.IsDropDownOpen)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private void ExecuteSaveAndCloseFromHotkey()
|
||
{
|
||
_logging.Debug("Настройки дорожек сохранены через Ctrl+Enter", "conversion.keyboard");
|
||
_saveAndCloseAction();
|
||
}
|
||
|
||
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
|
||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||
|
||
private void FillFileActuals()
|
||
{
|
||
var m = _item.MediaAnalysis;
|
||
if (m is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_fileContainer = NormalizeContainerForUi(m.ContainerFormat ?? m.FormatName, _item.FullPath);
|
||
_currentFileVideoBitrate = VideoBitratePolicy.FormatCurrentSource(m.SourceVideoBitrateBps);
|
||
if (m.PrimaryVideo is { } v)
|
||
{
|
||
_fileVideo = v.CodecName;
|
||
_filePixel = v.PixelFormat;
|
||
if (v.Width is { } w && v.Height is { } h)
|
||
{
|
||
_fileResolution = $"{w}x{h}";
|
||
}
|
||
|
||
if (v.FrameRate is { } f)
|
||
{
|
||
_fileFps = f.ToString("0.###", CultureInfo.InvariantCulture);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void RebuildRowsFromDraft()
|
||
{
|
||
TrackRows.Clear();
|
||
var media = _item.MediaAnalysis;
|
||
var n = 1;
|
||
foreach (var t in _draft.TrackOverrides)
|
||
{
|
||
MediaStreamInfo? em = t.Source == SourceKind.Embedded && media is not null
|
||
? media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)
|
||
: null;
|
||
TrackRows.Add(new TrackSettingsRowViewModel(this, t, n, em, _container));
|
||
n++;
|
||
}
|
||
}
|
||
|
||
/// <summary>Без snapshot: черновик уже из сохранённого состояния файла и автоплана анализа; только сброс UI статуса snapshot.</summary>
|
||
private void OpenWithAutoPlanOnly()
|
||
{
|
||
IsAutoAppliedFromSnapshot = false;
|
||
SnapshotStatusText = string.Empty;
|
||
HideToastInstant();
|
||
}
|
||
|
||
private void TryApplyPreviousSnapshot()
|
||
{
|
||
var current = BuildSnapshotItemsFromDraft();
|
||
var undRisk = TrackSettingsSnapshotService.TracksHaveRiskyMultipleUndTracks(current);
|
||
var applyResult = _snapshotService.TryApplySnapshot(current, _item.FullPath, _item.SnapshotScopeBatchRoot);
|
||
if (applyResult.Reason == SnapshotApplyReason.NoSnapshot)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (applyResult.Reason == SnapshotApplyReason.ScopeMismatch)
|
||
{
|
||
SnapshotStatusText = "Настройки из другого каталога или пакета — не применены.";
|
||
ShowToast(SnapshotToastNotApplied, ToastKind.Error);
|
||
return;
|
||
}
|
||
|
||
if (!applyResult.AppliedAny || applyResult.TrackResults is null)
|
||
{
|
||
SnapshotStatusText = SnapshotStatusStructureMismatch;
|
||
ShowToast(SnapshotToastNotApplied, ToastKind.Error);
|
||
return;
|
||
}
|
||
|
||
var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||
var appliedRows = 0;
|
||
foreach (var row in TrackRows)
|
||
{
|
||
var mr = applyResult.TrackResults.FirstOrDefault(r => r.CurrentOrder == row.IndexDisplay);
|
||
if (mr is not { IsMatched: true, SourceItem: { } src })
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!TryApplyMatchedSnapshotToRow(row, src, prof))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
row.AudioBitrateKbps = src.Bitrate;
|
||
row.Default = src.Default;
|
||
if (src.SnapshotTitleWasUserEdited && !string.IsNullOrWhiteSpace(src.Title))
|
||
{
|
||
row.Title = src.Title.Trim();
|
||
}
|
||
|
||
row.Language = string.IsNullOrWhiteSpace(src.Language) ? null : src.Language.Trim();
|
||
appliedRows++;
|
||
}
|
||
|
||
if (appliedRows == 0)
|
||
{
|
||
SnapshotStatusText = SnapshotStatusStructureMismatch;
|
||
ShowToast(SnapshotToastNotApplied, ToastKind.Error);
|
||
return;
|
||
}
|
||
|
||
IsAutoAppliedFromSnapshot = true;
|
||
var undSuffix = undRisk
|
||
? " Snapshot применён к und-дорожкам по порядку внутри группы (риск перепутать)."
|
||
: string.Empty;
|
||
var totalRows = TrackRows.Count;
|
||
|
||
ToastKind toastKind;
|
||
string toastCopy;
|
||
string statusLinePartial;
|
||
switch (applyResult.Degree)
|
||
{
|
||
case SnapshotApplyDegree.Full:
|
||
toastKind = ToastKind.Success;
|
||
toastCopy = SnapshotToastAppliedFull;
|
||
statusLinePartial = "Применены настройки предыдущего файла";
|
||
break;
|
||
case SnapshotApplyDegree.Partial:
|
||
toastKind = ToastKind.Warning;
|
||
toastCopy = SnapshotToastAppliedPartial;
|
||
statusLinePartial = "Частично применены настройки предыдущего файла";
|
||
var matchedInKeys = applyResult.TrackResults.Count(r => r.IsMatched);
|
||
_logging.Info($"Совпало {matchedInKeys} из {totalRows} дорожек", "conversion.snapshot");
|
||
break;
|
||
default:
|
||
toastKind = ToastKind.Error;
|
||
toastCopy = SnapshotToastNotApplied;
|
||
statusLinePartial = SnapshotStatusStructureMismatch;
|
||
break;
|
||
}
|
||
|
||
SnapshotStatusText = statusLinePartial + undSuffix;
|
||
ShowToast(toastCopy, toastKind);
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
}
|
||
|
||
private bool TryApplyMatchedSnapshotToRow(
|
||
TrackSettingsRowViewModel row,
|
||
TrackSettingsSnapshotItem src,
|
||
ConversionProfileSettingsEntry profile)
|
||
{
|
||
var entry = row.DataModel;
|
||
if (entry.StreamKind != src.StreamKind || entry.Source != src.Source)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var action = src.Action;
|
||
|
||
if (action == TrackActionKind.Add && entry.Source != SourceKind.External)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (action == TrackActionKind.Remove && entry.StreamKind == MediaStreamKind.Video)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (action == TrackActionKind.Convert
|
||
&& entry.Source == SourceKind.Embedded
|
||
&& _item.MediaAnalysis is { } media)
|
||
{
|
||
if (entry.StreamKind == MediaStreamKind.Audio && entry.StreamIndex >= 0)
|
||
{
|
||
var st = media.AllStreams.FirstOrDefault(
|
||
s => s.Index == entry.StreamIndex && s.Kind == MediaStreamKind.Audio);
|
||
if (st is not null && ConversionPlanService.EmbeddedAudioMatchesProfile(st.CodecName, profile))
|
||
{
|
||
action = TrackActionKind.Keep;
|
||
}
|
||
}
|
||
else if (entry.StreamKind == MediaStreamKind.Video && entry.StreamIndex >= 0)
|
||
{
|
||
var vst = media.AllStreams.FirstOrDefault(
|
||
s => s.Index == entry.StreamIndex && s.Kind == MediaStreamKind.Video);
|
||
if (vst is not null)
|
||
{
|
||
var targetV = string.IsNullOrWhiteSpace(_draft.TargetVideo) ? profile.Video : _draft.TargetVideo;
|
||
if (ConversionPlanService.VideoCodecMatchesTarget(vst.CodecName, targetV))
|
||
{
|
||
action = TrackActionKind.Keep;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!row.ValidActions.Contains(action))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
row.Action = action;
|
||
return true;
|
||
}
|
||
|
||
private void ExecuteUndoAutoApply()
|
||
{
|
||
if (_item.MediaAnalysis is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var profile = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||
var reset = new ConversionTaskOverride();
|
||
TrackOverrideSeeder.EnsureDefaults(
|
||
reset,
|
||
_item.MediaAnalysis,
|
||
_item.Sidecars,
|
||
profile,
|
||
externalAudio: _item.ExternalAudioFiles,
|
||
videoPath: _item.FullPath);
|
||
_draft.CopyFrom(reset);
|
||
|
||
RebuildRowsFromDraft();
|
||
IsAutoAppliedFromSnapshot = false;
|
||
SnapshotStatusText = string.Empty;
|
||
HideToastInstant();
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
}
|
||
|
||
private void SaveCurrentSnapshot()
|
||
{
|
||
var items = BuildSnapshotItemsFromDraft();
|
||
_snapshotService.SaveSnapshot(_item.FullPath, items, _item.SnapshotScopeBatchRoot);
|
||
}
|
||
|
||
private IReadOnlyList<TrackSettingsSnapshotItem> BuildSnapshotItemsFromDraft()
|
||
{
|
||
var list = new List<TrackSettingsSnapshotItem>(_draft.TrackOverrides.Count);
|
||
var media = _item.MediaAnalysis;
|
||
var prof = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||
var order = 1;
|
||
foreach (var t in _draft.TrackOverrides)
|
||
{
|
||
var codec = ResolveCodecForSnapshot(t, media);
|
||
list.Add(new TrackSettingsSnapshotItem
|
||
{
|
||
Order = order,
|
||
StreamKind = t.StreamKind,
|
||
Source = t.Source,
|
||
Codec = codec,
|
||
Language = (t.Language ?? string.Empty).Trim(),
|
||
Title = (t.Title ?? string.Empty).Trim(),
|
||
Action = t.Action,
|
||
Bitrate = t.AudioBitrateKbps,
|
||
Default = t.Default,
|
||
TargetCodec = t.StreamKind switch
|
||
{
|
||
MediaStreamKind.Video => _draft.TargetVideo,
|
||
MediaStreamKind.Audio => prof.Audio,
|
||
_ => null
|
||
},
|
||
SnapshotTitleWasUserEdited = IsSnapshotTitleEditedByUser(t, media)
|
||
});
|
||
order++;
|
||
}
|
||
|
||
return list;
|
||
}
|
||
|
||
private bool IsSnapshotTitleEditedByUser(TrackOverrideEntry t, MediaAnalysisResult? media)
|
||
{
|
||
var canonical = CanonicalTitleFromSource(t, media);
|
||
var a = TrackSettingsSnapshotService.NormalizeTitleFingerprint(t.Title);
|
||
var b = TrackSettingsSnapshotService.NormalizeTitleFingerprint(canonical);
|
||
return !string.Equals(a, b, StringComparison.Ordinal);
|
||
}
|
||
|
||
private string? CanonicalTitleFromSource(TrackOverrideEntry t, MediaAnalysisResult? media)
|
||
{
|
||
if (media is not null && t.Source == SourceKind.Embedded && t.StreamIndex >= 0)
|
||
{
|
||
var s = media.AllStreams.FirstOrDefault(x => x.Index == t.StreamIndex);
|
||
return s?.Title;
|
||
}
|
||
|
||
if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
{
|
||
return TrackOverrideSeeder.ExternalAudioCanonicalTitleFromEntry(_draft.TrackOverrides, t, _item.FullPath);
|
||
}
|
||
|
||
if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Subtitle && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
{
|
||
return TrackOverrideSeeder.ExternalSubtitleCanonicalTitle(_draft.TrackOverrides, t, _item.FullPath);
|
||
}
|
||
|
||
if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Attachment && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
{
|
||
return Path.GetFileName(t.ExternalPath);
|
||
}
|
||
|
||
if (t.Source == SourceKind.External && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
{
|
||
return Path.GetFileNameWithoutExtension(Path.GetFileName(t.ExternalPath));
|
||
}
|
||
|
||
return string.Empty;
|
||
}
|
||
|
||
private static string ResolveCodecForSnapshot(TrackOverrideEntry t, MediaAnalysisResult? media)
|
||
{
|
||
if (t.Source == SourceKind.Embedded && media is not null && t.StreamIndex >= 0)
|
||
{
|
||
var src = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
|
||
if (!string.IsNullOrWhiteSpace(src?.CodecName))
|
||
{
|
||
return src!.CodecName;
|
||
}
|
||
}
|
||
|
||
if (t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalStreamCodec))
|
||
{
|
||
return t.ExternalStreamCodec.Trim();
|
||
}
|
||
|
||
if (t.Source == SourceKind.External && !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
{
|
||
var ext = Path.GetExtension(t.ExternalPath);
|
||
if (!string.IsNullOrWhiteSpace(ext))
|
||
{
|
||
return ext.TrimStart('.').ToLowerInvariant();
|
||
}
|
||
}
|
||
|
||
return string.Empty;
|
||
}
|
||
|
||
private void RemoveForeignTracks()
|
||
{
|
||
foreach (var row in TrackRows)
|
||
{
|
||
var t = row.DataModel;
|
||
if (t.Source != SourceKind.Embedded)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (t.StreamKind is not (MediaStreamKind.Audio or MediaStreamKind.Subtitle))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(t.Language))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var lang = t.Language!.Trim().ToLowerInvariant();
|
||
if (lang is "und" or "unknown" or "?")
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (lang is "rus" or "ru")
|
||
{
|
||
continue;
|
||
}
|
||
|
||
row.Action = TrackActionKind.Remove;
|
||
}
|
||
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
}
|
||
|
||
private bool ValidateBeforeSave(bool showMessage)
|
||
{
|
||
ValidateDefaultConflicts();
|
||
var audioConflicts = TrackRows.Count(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Audio);
|
||
var subConflicts = TrackRows.Count(r => r.HasDefaultConflict && r.DataModel.StreamKind == MediaStreamKind.Subtitle);
|
||
if (audioConflicts > 0 || subConflicts > 0)
|
||
{
|
||
if (showMessage)
|
||
{
|
||
MessageBox.Show(
|
||
"Ошибка: для Audio и Subtitle может быть только одна дорожка Default (исключая Remove).",
|
||
"Валидация",
|
||
MessageBoxButton.OK,
|
||
MessageBoxImage.Warning);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
if (string.Equals(_videoBitrateMode, VideoBitratePolicy.Custom, StringComparison.Ordinal)
|
||
&& !TryParseCustomVideoBitrate(_videoBitrateCustomMbps, out _))
|
||
{
|
||
if (showMessage)
|
||
{
|
||
MessageBox.Show(
|
||
"Поле Custom bitrate, Mbps должно быть числом больше 0.",
|
||
"Валидация",
|
||
MessageBoxButton.OK,
|
||
MessageBoxImage.Warning);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private void OnSelectionChanged(object? parameter)
|
||
{
|
||
SelectedTracks.Clear();
|
||
if (parameter is IList list)
|
||
{
|
||
foreach (var r in list.OfType<TrackSettingsRowViewModel>())
|
||
{
|
||
SelectedTracks.Add(r);
|
||
}
|
||
}
|
||
|
||
SyncBulkControlsFromSelection();
|
||
ContextSetActionCommand.RaiseCanExecuteChanged();
|
||
ContextSetBitrateCommand.RaiseCanExecuteChanged();
|
||
SetSelectedTracksRemoveCommand.RaiseCanExecuteChanged();
|
||
}
|
||
|
||
private bool CanSetSelectedTracksRemove(object? parameter)
|
||
{
|
||
if (parameter is not IList list || list.Count == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return list.OfType<TrackSettingsRowViewModel>().Any(r => r.ValidActions.Contains(TrackActionKind.Remove));
|
||
}
|
||
|
||
private void SetSelectedTracksRemove(object? parameter)
|
||
{
|
||
if (parameter is not IList list || list.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var rows = list.OfType<TrackSettingsRowViewModel>().ToList();
|
||
var changed = false;
|
||
foreach (var row in rows)
|
||
{
|
||
if (!row.ValidActions.Contains(TrackActionKind.Remove))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
row.Action = TrackActionKind.Remove;
|
||
changed = true;
|
||
}
|
||
|
||
if (changed)
|
||
{
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
SyncBulkControlsFromSelection();
|
||
}
|
||
}
|
||
|
||
private bool CanApplyContextAction(object? parameter)
|
||
{
|
||
if (parameter is not TrackActionKind action)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var rows = SelectedTracks.ToList();
|
||
if (rows.Count == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return rows.Any(r => CanApplyActionToRow(r, action));
|
||
}
|
||
|
||
private void ApplyContextAction(object? parameter)
|
||
{
|
||
if (parameter is not TrackActionKind action)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var rows = SelectedTracks.ToList();
|
||
var changed = false;
|
||
foreach (var row in rows)
|
||
{
|
||
if (!CanApplyActionToRow(row, action))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
row.Action = action;
|
||
changed = true;
|
||
}
|
||
|
||
if (changed)
|
||
{
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
SyncBulkControlsFromSelection();
|
||
}
|
||
}
|
||
|
||
private bool CanApplyContextBitrate(object? parameter)
|
||
{
|
||
if (parameter is not string br || string.IsNullOrWhiteSpace(br))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return SelectedTracks.Any(r => r.DataModel.StreamKind == MediaStreamKind.Audio);
|
||
}
|
||
|
||
private void ApplyContextBitrate(object? parameter)
|
||
{
|
||
if (parameter is not string br || string.IsNullOrWhiteSpace(br))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var rows = SelectedTracks.ToList();
|
||
var changed = false;
|
||
foreach (var row in rows.Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio))
|
||
{
|
||
row.AudioBitrateKbps = br;
|
||
changed = true;
|
||
}
|
||
|
||
if (changed)
|
||
{
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
SyncBulkControlsFromSelection();
|
||
}
|
||
}
|
||
|
||
private static bool CanApplyActionToRow(TrackSettingsRowViewModel row, TrackActionKind action)
|
||
{
|
||
if (action == TrackActionKind.Add && row.DataModel.Source != SourceKind.External)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (action == TrackActionKind.Convert && row.DataModel.StreamKind is MediaStreamKind.Subtitle or MediaStreamKind.Attachment)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return row.ValidActions.Contains(action);
|
||
}
|
||
|
||
private void ApplyBulkActionOnChange(TrackActionKind action)
|
||
{
|
||
var rows = GetBulkTargetRows().ToList();
|
||
if (rows.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var row in rows)
|
||
{
|
||
if (row.ValidActions.Contains(action))
|
||
{
|
||
row.Action = action;
|
||
}
|
||
}
|
||
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
SyncBulkControlsFromSelection();
|
||
}
|
||
|
||
private void ApplyBulkBitrateOnChange(string bitrate)
|
||
{
|
||
var changed = false;
|
||
foreach (var row in GetBulkTargetRows().Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio))
|
||
{
|
||
row.AudioBitrateKbps = bitrate;
|
||
changed = true;
|
||
}
|
||
|
||
if (changed)
|
||
{
|
||
ValidateDefaultConflicts();
|
||
RecalculatePlanPreview();
|
||
SyncBulkControlsFromSelection();
|
||
}
|
||
}
|
||
|
||
private IEnumerable<TrackSettingsRowViewModel> GetBulkTargetRows() =>
|
||
SelectedTracks.Where(IsTrackTypeMatch);
|
||
|
||
private bool IsTrackTypeMatch(TrackSettingsRowViewModel row) =>
|
||
BulkTrackType switch
|
||
{
|
||
"Видео" => row.DataModel.StreamKind == MediaStreamKind.Video,
|
||
"Аудио" => row.DataModel.StreamKind == MediaStreamKind.Audio,
|
||
"Субтитры" => row.DataModel.StreamKind == MediaStreamKind.Subtitle,
|
||
"Attachments" => row.DataModel.StreamKind == MediaStreamKind.Attachment,
|
||
_ => true
|
||
};
|
||
|
||
private void SyncBulkControlsFromSelection()
|
||
{
|
||
_isSyncingBulkFromSelection = true;
|
||
try
|
||
{
|
||
var rows = GetBulkTargetRows().ToList();
|
||
if (rows.Count == 0)
|
||
{
|
||
BulkActionValue = null;
|
||
BulkBitrateValue = null;
|
||
IsBulkBitrateEnabled = false;
|
||
return;
|
||
}
|
||
|
||
var firstAction = rows[0].Action;
|
||
BulkActionValue = rows.All(r => r.Action == firstAction) ? firstAction : null;
|
||
|
||
var audioRows = rows.Where(r => r.DataModel.StreamKind == MediaStreamKind.Audio).ToList();
|
||
IsBulkBitrateEnabled = audioRows.Count > 0;
|
||
if (audioRows.Count == 0)
|
||
{
|
||
BulkBitrateValue = null;
|
||
return;
|
||
}
|
||
|
||
var firstBr = audioRows[0].AudioBitrateKbps;
|
||
BulkBitrateValue = audioRows.All(r => string.Equals(r.AudioBitrateKbps, firstBr, StringComparison.Ordinal)) ? firstBr : null;
|
||
}
|
||
finally
|
||
{
|
||
_isSyncingBulkFromSelection = false;
|
||
}
|
||
|
||
ContextSetActionCommand.RaiseCanExecuteChanged();
|
||
ContextSetBitrateCommand.RaiseCanExecuteChanged();
|
||
}
|
||
|
||
private bool IsDraftDifferentFromAutoPlan()
|
||
{
|
||
if (_item.MediaAnalysis is null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var profile = _profile.GetProfile(_item.Profile) ?? ConversionProfileMapping.EmbyFallback;
|
||
var auto = new ConversionTaskOverride();
|
||
TrackOverrideSeeder.EnsureDefaults(
|
||
auto,
|
||
_item.MediaAnalysis,
|
||
_item.Sidecars,
|
||
profile,
|
||
externalAudio: _item.ExternalAudioFiles,
|
||
videoPath: _item.FullPath);
|
||
return !OverridesEquivalent(_draft, auto);
|
||
}
|
||
|
||
private static bool OverridesEquivalent(ConversionTaskOverride left, ConversionTaskOverride right)
|
||
{
|
||
if (!StringEq(left.TargetContainer, right.TargetContainer) ||
|
||
!StringEq(left.TargetVideo, right.TargetVideo) ||
|
||
!StringEq(left.TargetPixelFormat, right.TargetPixelFormat) ||
|
||
!StringEq(left.TargetResolution, right.TargetResolution) ||
|
||
!StringEq(left.TargetFps, right.TargetFps) ||
|
||
!StringEq(left.TargetAudioBitrate, right.TargetAudioBitrate) ||
|
||
!StringEq(left.TargetVideoBitrateMode, right.TargetVideoBitrateMode) ||
|
||
left.TargetVideoBitrateMbps != right.TargetVideoBitrateMbps)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var l = left.TrackOverrides.OrderBy(TrackKey).ToArray();
|
||
var r = right.TrackOverrides.OrderBy(TrackKey).ToArray();
|
||
if (l.Length != r.Length)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
for (var i = 0; i < l.Length; i++)
|
||
{
|
||
if (!TrackEquivalent(l[i], r[i]))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private static bool TrackEquivalent(TrackOverrideEntry a, TrackOverrideEntry b)
|
||
{
|
||
return a.StreamIndex == b.StreamIndex
|
||
&& a.Source == b.Source
|
||
&& a.StreamKind == b.StreamKind
|
||
&& a.Action == b.Action
|
||
&& a.Default == b.Default
|
||
&& StringEq(a.ExternalPath, b.ExternalPath)
|
||
&& a.ExternalAudioStreamOrdinal == b.ExternalAudioStreamOrdinal
|
||
&& StringEq(a.ExternalStreamCodec, b.ExternalStreamCodec)
|
||
&& StringEq(a.ExternalFfprobeTitle, b.ExternalFfprobeTitle)
|
||
&& StringEq(a.Language, b.Language)
|
||
&& StringEq(a.Title, b.Title)
|
||
&& StringEq(a.AudioBitrateKbps, b.AudioBitrateKbps);
|
||
}
|
||
|
||
private static string TrackKey(TrackOverrideEntry t) =>
|
||
$"{(int)t.Source}|{(int)t.StreamKind}|{t.StreamIndex}|{(t.ExternalPath ?? string.Empty).Trim()}|a{t.ExternalAudioStreamOrdinal}";
|
||
|
||
private static bool StringEq(string? a, string? b) =>
|
||
string.Equals((a ?? string.Empty).Trim(), (b ?? string.Empty).Trim(), StringComparison.Ordinal);
|
||
|
||
private static bool TryParseCustomVideoBitrate(string? raw, out double mbps)
|
||
{
|
||
mbps = 0;
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return double.TryParse(raw.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out mbps)
|
||
&& mbps > 0;
|
||
}
|
||
|
||
private bool CanExecutePlayFile(object? parameter)
|
||
{
|
||
if (!TryResolvePlayFullPath(parameter, out var path))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return File.Exists(path);
|
||
}
|
||
|
||
private void ExecutePlayFile(object? parameter)
|
||
{
|
||
if (!TryResolvePlayFullPath(parameter, out var path))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!File.Exists(path))
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
Process.Start(
|
||
new ProcessStartInfo
|
||
{
|
||
FileName = path,
|
||
UseShellExecute = true,
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logging.Error($"Не удалось воспроизвести файл «{path}»: {ex.Message}", "conversion.fileSettings", ex);
|
||
}
|
||
}
|
||
|
||
private bool TryResolvePlayFullPath(object? parameter, out string path)
|
||
{
|
||
path = string.Empty;
|
||
var raw = parameter as string;
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
{
|
||
raw = FilePath;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
raw = raw.Trim();
|
||
|
||
try
|
||
{
|
||
path = Path.GetFullPath(raw);
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static string NormalizeContainerForUi(string? rawFormatName, string filePath)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(rawFormatName))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var raw = rawFormatName.Trim();
|
||
var ext = Path.GetExtension(filePath).Trim().ToLowerInvariant();
|
||
|
||
if (raw.Contains("matroska", StringComparison.OrdinalIgnoreCase) || raw.Contains("webm", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return ext switch
|
||
{
|
||
".webm" => "WebM",
|
||
".mkv" => "MKV",
|
||
_ => "MKV/WebM"
|
||
};
|
||
}
|
||
|
||
if (raw.Contains("mp4", StringComparison.OrdinalIgnoreCase) || raw.Contains("mov", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return ext switch
|
||
{
|
||
".mov" => "MOV",
|
||
".mp4" or ".m4v" => "MP4",
|
||
_ => "MP4/MOV"
|
||
};
|
||
}
|
||
|
||
if (raw.Contains("avi", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "AVI";
|
||
}
|
||
|
||
if (raw.Contains("mpegts", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "TS";
|
||
}
|
||
|
||
if (raw.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "MPEG";
|
||
}
|
||
|
||
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? raw;
|
||
}
|
||
}
|