emby-toolbox/EmbyToolbox/ViewModels/FileConversionSettingsViewModel.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

1502 lines
46 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;
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;
}
}