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;
/// Порог длины текста (символы): дольше — показ до с.
private const int ToastLongMessageCharThreshold = 56;
/// Строки для toast (кратко и заметно); подробности — в статусе внизу.
private const string SnapshotToastAppliedFull =
"Настройки предыдущего файла применены";
private const string SnapshotToastAppliedPartial =
"Частично применены настройки предыдущего файла";
/// Единый текст toast при любой неудаче автоприменения snapshot.
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 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 TrackRows { get; } = new();
public ObservableCollection 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 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; }
/// Подсказка для кнопки воспроизведения (файл существует / нет).
public string PlayFileToolTip =>
CanExecutePlayFile(FilePath) ? "Воспроизвести файл" : "Файл не найден";
public IReadOnlyList BulkTrackTypeOptions { get; } = ["Все", "Видео", "Аудио", "Субтитры", "Attachments"];
public IReadOnlyList BulkActionOptions { get; } = [TrackActionKind.Keep, TrackActionKind.Convert, TrackActionKind.Remove, TrackActionKind.Add];
public IReadOnlyList 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++;
}
}
/// Без snapshot: черновик уже из сохранённого состояния файла и автоплана анализа; только сброс UI статуса snapshot.
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 BuildSnapshotItemsFromDraft()
{
var list = new List(_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())
{
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().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().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 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;
}
}