674 lines
20 KiB
C#
674 lines
20 KiB
C#
using System.Collections.Generic;
|
||
using System.ComponentModel;
|
||
using System.IO;
|
||
using System.Runtime.CompilerServices;
|
||
|
||
namespace EmbyToolbox.Models;
|
||
|
||
public sealed class ConversionQueueItem : INotifyPropertyChanged
|
||
{
|
||
private string _fullPath;
|
||
private string _fileName;
|
||
private string _directoryPath;
|
||
private int _orderNumber;
|
||
private int _progress;
|
||
private string _status = ConversionQueueStatus.Pending;
|
||
private string _profile = "Emby";
|
||
private string _planSummary = string.Empty;
|
||
private int _fileSizeMb;
|
||
/// <summary>True после успешного ffprobe (аудио-поля валидны для отображения).</summary>
|
||
private bool _ffprobeAnalyzed;
|
||
private int _ffprobeAudioCount;
|
||
private int? _ffprobeAudioSizeMb;
|
||
private bool _ffprobeAudioSizePartial;
|
||
private MediaAnalysisResult? _mediaAnalysis;
|
||
private IReadOnlyList<SidecarFile> _sidecars = System.Array.Empty<SidecarFile>();
|
||
private IReadOnlyList<ExternalAudioFile> _externalAudioFiles = System.Array.Empty<ExternalAudioFile>();
|
||
private EffectiveProfileSettings? _effectiveProfileSettings;
|
||
private ConversionPlan? _lastPlan;
|
||
private bool _isProcessed;
|
||
private bool _processedInCurrentRun;
|
||
private string? _lastRunId;
|
||
private bool _isSkipPlan = true;
|
||
private bool _isManuallyEdited;
|
||
private string? _errorMessage;
|
||
private string? _errorDetails;
|
||
|
||
public string FullPath
|
||
{
|
||
get => _fullPath;
|
||
private set
|
||
{
|
||
if (string.Equals(_fullPath, value, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_fullPath = value;
|
||
_directoryPath = Path.GetDirectoryName(value) ?? string.Empty;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(DirectoryPath));
|
||
}
|
||
}
|
||
|
||
public string FileName
|
||
{
|
||
get => _fileName;
|
||
private set
|
||
{
|
||
if (string.Equals(_fileName, value, StringComparison.Ordinal))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_fileName = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public string DirectoryPath
|
||
{
|
||
get => _directoryPath;
|
||
private set
|
||
{
|
||
if (string.Equals(_directoryPath, value, StringComparison.Ordinal))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_directoryPath = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
/// <summary>Параметры из ffprobe + списки потоков.</summary>
|
||
public MediaAnalysisResult? MediaAnalysis
|
||
{
|
||
get => _mediaAnalysis;
|
||
private set
|
||
{
|
||
if (ReferenceEquals(_mediaAnalysis, value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_mediaAnalysis = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(TrackSummaryDisplay));
|
||
}
|
||
}
|
||
|
||
public IReadOnlyList<SidecarFile> Sidecars
|
||
{
|
||
get => _sidecars;
|
||
private set
|
||
{
|
||
if (Equals(_sidecars, value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_sidecars = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
/// <summary>Общий корень батча (добавление каталогом или вычисленный LCA файлов при перетаскивании/мультовыборе). Для области snapshot между эпизодами одного добавления.</summary>
|
||
public string? SnapshotScopeBatchRoot { get; set; }
|
||
|
||
/// <summary>Разбор внешних аудиофайлов (мультипотоковые контейнеры и т.д.) для пере-seed дорожек.</summary>
|
||
public IReadOnlyList<ExternalAudioFile> ExternalAudioFiles
|
||
{
|
||
get => _externalAudioFiles;
|
||
private set
|
||
{
|
||
if (Equals(_externalAudioFiles, value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_externalAudioFiles = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public ConversionTaskOverride TaskOverride { get; } = new();
|
||
|
||
public EffectiveProfileSettings? EffectiveProfileSettings
|
||
{
|
||
get => _effectiveProfileSettings;
|
||
set
|
||
{
|
||
if (ReferenceEquals(_effectiveProfileSettings, value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_effectiveProfileSettings = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public ConversionPlan? LastPlan
|
||
{
|
||
get => _lastPlan;
|
||
private set
|
||
{
|
||
if (Equals(_lastPlan, value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_lastPlan = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public ConversionQueueItem(string fullPath)
|
||
{
|
||
var normalized = Path.GetFullPath(fullPath);
|
||
_fullPath = normalized;
|
||
_fileName = Path.GetFileName(normalized);
|
||
_directoryPath = Path.GetDirectoryName(normalized) ?? string.Empty;
|
||
SetInitialFileSizeBytes();
|
||
}
|
||
|
||
/// <summary>Размер файла, МБ (целое, округление).</summary>
|
||
public int FileSizeMb
|
||
{
|
||
get => _fileSizeMb;
|
||
private set
|
||
{
|
||
if (_fileSizeMb == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_fileSizeMb = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
private void SetInitialFileSizeBytes()
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(FullPath))
|
||
{
|
||
var len = new FileInfo(FullPath).Length;
|
||
FileSizeMb = (int)Math.Round(len / 1024.0 / 1024.0, MidpointRounding.AwayFromZero);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
FileSizeMb = 0;
|
||
}
|
||
}
|
||
|
||
public void RefreshFileSizeFromDisk() => SetInitialFileSizeBytes();
|
||
|
||
/// <summary>Восстановление медиаданных из снимка (загрузка очереди) без затрагивания <see cref="IsManuallyEdited"/>.</summary>
|
||
public void RestorePersistedMediaSnapshot(
|
||
MediaAnalysisResult media,
|
||
IReadOnlyList<SidecarFile> sidecars,
|
||
IReadOnlyList<ExternalAudioFile> externalAudioFiles,
|
||
bool hasFfprobeAudioSummary,
|
||
int ffprobeAudioCount,
|
||
int? ffprobeAudioSizeMb,
|
||
bool ffprobeAudioSizePartial)
|
||
{
|
||
MediaAnalysis = media;
|
||
Sidecars = sidecars;
|
||
ExternalAudioFiles = externalAudioFiles;
|
||
if (hasFfprobeAudioSummary)
|
||
{
|
||
SetFfprobeAudioData(ffprobeAudioCount, ffprobeAudioSizeMb, ffprobeAudioSizePartial);
|
||
}
|
||
else
|
||
{
|
||
_ffprobeAnalyzed = false;
|
||
_ffprobeAudioCount = 0;
|
||
_ffprobeAudioSizeMb = null;
|
||
_ffprobeAudioSizePartial = false;
|
||
OnPropertyChanged(nameof(AudioTracksDisplay));
|
||
OnPropertyChanged(nameof(AudioTracksSortValue));
|
||
OnPropertyChanged(nameof(AudioSizeMbDisplay));
|
||
OnPropertyChanged(nameof(AudioSizeSortValue));
|
||
OnPropertyChanged(nameof(AudioSizeMbToolTip));
|
||
OnPropertyChanged(nameof(HasFfprobeAudioSummary));
|
||
}
|
||
}
|
||
|
||
/// <summary>Есть ли сохранённые краткие данные ffprobe по аудио (для .conv_setup).</summary>
|
||
public bool HasFfprobeAudioSummary => _ffprobeAnalyzed;
|
||
|
||
/// <summary>Число аудиопотоков из последнего анализа (0, если не анализировалось).</summary>
|
||
public int FfprobeEmbeddedAudioStreamCount => _ffprobeAnalyzed ? _ffprobeAudioCount : 0;
|
||
|
||
public int? FfprobeAudioSizeEstimateMb => _ffprobeAudioSizeMb;
|
||
|
||
public bool FfprobeAudioSizeEstimatePartial => _ffprobeAudioSizePartial;
|
||
|
||
public string AudioTracksDisplay => !_ffprobeAnalyzed ? "-" : _ffprobeAudioCount.ToString();
|
||
public int AudioTracksSortValue => _ffprobeAnalyzed ? _ffprobeAudioCount : -1;
|
||
|
||
public string AudioSizeMbDisplay
|
||
{
|
||
get
|
||
{
|
||
if (!_ffprobeAnalyzed)
|
||
{
|
||
return "-";
|
||
}
|
||
|
||
if (_ffprobeAudioSizeMb is not int audioMb)
|
||
{
|
||
return "-";
|
||
}
|
||
|
||
return _ffprobeAudioSizePartial ? $"{audioMb}*" : audioMb.ToString();
|
||
}
|
||
}
|
||
|
||
public string TrackSummaryDisplay
|
||
{
|
||
get
|
||
{
|
||
if (MediaAnalysis is null)
|
||
{
|
||
return "-";
|
||
}
|
||
|
||
var videoTotal = MediaAnalysis.VideoStreams.Count;
|
||
var audioTotal = MediaAnalysis.AudioStreams.Count;
|
||
var subtitleTotal = MediaAnalysis.SubtitleStreams.Count;
|
||
var attachmentTotal = MediaAnalysis.AllStreams.Count(s => s.Kind == MediaStreamKind.Attachment);
|
||
var overrides = TaskOverride.TrackOverrides;
|
||
if (overrides.Count > 0)
|
||
{
|
||
foreach (var track in overrides)
|
||
{
|
||
if (track.StreamKind == MediaStreamKind.Video)
|
||
{
|
||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||
{
|
||
videoTotal--;
|
||
}
|
||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||
{
|
||
videoTotal++;
|
||
}
|
||
}
|
||
else if (track.StreamKind == MediaStreamKind.Audio)
|
||
{
|
||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||
{
|
||
audioTotal--;
|
||
}
|
||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||
{
|
||
audioTotal++;
|
||
}
|
||
}
|
||
else if (track.StreamKind == MediaStreamKind.Subtitle)
|
||
{
|
||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||
{
|
||
subtitleTotal--;
|
||
}
|
||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||
{
|
||
subtitleTotal++;
|
||
}
|
||
}
|
||
else if (track.StreamKind == MediaStreamKind.Attachment)
|
||
{
|
||
if (track.Source == SourceKind.Embedded && track.Action == TrackActionKind.Remove)
|
||
{
|
||
attachmentTotal--;
|
||
}
|
||
else if (track.Source == SourceKind.External && track.Action == TrackActionKind.Add)
|
||
{
|
||
attachmentTotal++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (videoTotal < 0)
|
||
{
|
||
videoTotal = 0;
|
||
}
|
||
|
||
if (audioTotal < 0)
|
||
{
|
||
audioTotal = 0;
|
||
}
|
||
|
||
if (subtitleTotal < 0)
|
||
{
|
||
subtitleTotal = 0;
|
||
}
|
||
|
||
if (attachmentTotal < 0)
|
||
{
|
||
attachmentTotal = 0;
|
||
}
|
||
|
||
return $"🎬 {videoTotal} 🔊 {audioTotal} 💬 {subtitleTotal} 📎 {attachmentTotal}";
|
||
}
|
||
}
|
||
|
||
public int AudioSizeSortValue => _ffprobeAnalyzed && _ffprobeAudioSizeMb is { } mb ? mb : -1;
|
||
|
||
/// <summary>Подсказка для «размер аудио» с пометкой * о частичном расчёте.</summary>
|
||
public string? AudioSizeMbToolTip
|
||
{
|
||
get
|
||
{
|
||
if (_ffprobeAnalyzed && _ffprobeAudioSizePartial)
|
||
{
|
||
return "* расчет частичный, у части дорожек неизвестен bitrate";
|
||
}
|
||
|
||
if (_ffprobeAnalyzed && _ffprobeAudioSizeMb is not null && _ffprobeAudioSizeMb >= 0)
|
||
{
|
||
return "Суммарная оценка: длительность × битрейт по дорожкам, МБ (целое).";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public void SetFfprobeAudioData(int trackCount, int? audioSizeMb, bool isPartial)
|
||
{
|
||
_ffprobeAnalyzed = true;
|
||
_ffprobeAudioCount = trackCount;
|
||
_ffprobeAudioSizeMb = audioSizeMb;
|
||
_ffprobeAudioSizePartial = isPartial;
|
||
OnPropertyChanged(nameof(AudioTracksDisplay));
|
||
OnPropertyChanged(nameof(AudioTracksSortValue));
|
||
OnPropertyChanged(nameof(AudioSizeMbDisplay));
|
||
OnPropertyChanged(nameof(AudioSizeSortValue));
|
||
OnPropertyChanged(nameof(AudioSizeMbToolTip));
|
||
OnPropertyChanged(nameof(HasFfprobeAudioSummary));
|
||
}
|
||
|
||
public void SetSuccessfulMediaAnalysis(
|
||
MediaAnalysisResult media,
|
||
IReadOnlyList<SidecarFile> sidecars,
|
||
IReadOnlyList<ExternalAudioFile> externalAudioFiles,
|
||
ConversionPlan plan,
|
||
int audioCount,
|
||
int? audioSizeMb,
|
||
bool audioSizePartial)
|
||
{
|
||
MediaAnalysis = media;
|
||
Sidecars = sidecars;
|
||
ExternalAudioFiles = externalAudioFiles;
|
||
LastPlan = plan;
|
||
PlanSummary = string.IsNullOrWhiteSpace(plan.ShortSummary) ? "Skip — обработка не требуется" : plan.ShortSummary;
|
||
IsManuallyEdited = false;
|
||
RecomputeSkipFlag();
|
||
SetFfprobeAudioData(audioCount, audioSizeMb, audioSizePartial);
|
||
}
|
||
|
||
public void SetPlan(ConversionPlan plan)
|
||
{
|
||
LastPlan = plan;
|
||
PlanSummary = string.IsNullOrWhiteSpace(plan.ShortSummary) ? "Skip — обработка не требуется" : plan.ShortSummary;
|
||
RecomputeSkipFlag();
|
||
OnPropertyChanged(nameof(TrackSummaryDisplay));
|
||
}
|
||
|
||
public int OrderNumber
|
||
{
|
||
get => _orderNumber;
|
||
set
|
||
{
|
||
if (_orderNumber == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_orderNumber = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(DisplayIndexText));
|
||
}
|
||
}
|
||
|
||
public string Status
|
||
{
|
||
get => _status;
|
||
set
|
||
{
|
||
if (_status == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_status = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(StatusSortOrder));
|
||
OnPropertyChanged(nameof(DisplayProgressPercent));
|
||
}
|
||
}
|
||
|
||
public int StatusSortOrder => _status switch
|
||
{
|
||
ConversionQueueStatus.Pending => 0,
|
||
ConversionQueueStatus.Running => 1,
|
||
ConversionQueueStatus.Copying => 2,
|
||
ConversionQueueStatus.Replacing => 3,
|
||
ConversionQueueStatus.Done => 4,
|
||
ConversionQueueStatus.Error => 5,
|
||
ConversionQueueStatus.Cancelled => 6,
|
||
_ => 99
|
||
};
|
||
|
||
/// <summary>Сырое значение 0–100 для пайплайна (ffmpeg/копирование); в UI использовать <see cref="DisplayProgressPercent"/>.</summary>
|
||
public int Progress
|
||
{
|
||
get => _progress;
|
||
set
|
||
{
|
||
if (_progress == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_progress = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(DisplayProgressPercent));
|
||
}
|
||
}
|
||
|
||
/// <summary>Прогресс для интерфейса: 100% только при статусе «Готово»; иначе не выше 99.</summary>
|
||
public int DisplayProgressPercent =>
|
||
string.Equals(_status, ConversionQueueStatus.Done, StringComparison.Ordinal)
|
||
? 100
|
||
: Math.Min(99, Math.Max(0, _progress));
|
||
|
||
public string Profile
|
||
{
|
||
get => _profile;
|
||
set
|
||
{
|
||
if (_profile == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_profile = value;
|
||
EffectiveProfileSettings = null;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public string PlanSummary
|
||
{
|
||
get => _planSummary;
|
||
set
|
||
{
|
||
if (_planSummary == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_planSummary = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public bool IsSkipPlan
|
||
{
|
||
get => _isSkipPlan;
|
||
set
|
||
{
|
||
if (_isSkipPlan == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isSkipPlan = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public bool IsManuallyEdited
|
||
{
|
||
get => _isManuallyEdited;
|
||
set
|
||
{
|
||
if (_isManuallyEdited == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isManuallyEdited = value;
|
||
OnPropertyChanged();
|
||
OnPropertyChanged(nameof(DisplayIndexText));
|
||
OnPropertyChanged(nameof(ManualEditToolTip));
|
||
RecomputeSkipFlag();
|
||
}
|
||
}
|
||
|
||
public string DisplayIndexText => IsManuallyEdited ? $"✎ {OrderNumber}" : OrderNumber.ToString();
|
||
|
||
public string? ManualEditToolTip => IsManuallyEdited ? "Настройки изменены вручную" : null;
|
||
|
||
public bool IsProcessed
|
||
{
|
||
get => _isProcessed;
|
||
set
|
||
{
|
||
if (_isProcessed == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_isProcessed = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public bool ProcessedInCurrentRun
|
||
{
|
||
get => _processedInCurrentRun;
|
||
set
|
||
{
|
||
if (_processedInCurrentRun == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_processedInCurrentRun = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public string? LastRunId
|
||
{
|
||
get => _lastRunId;
|
||
set
|
||
{
|
||
if (_lastRunId == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_lastRunId = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public string? ErrorMessage
|
||
{
|
||
get => _errorMessage;
|
||
set
|
||
{
|
||
if (_errorMessage == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_errorMessage = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
/// <summary>Подробный текст ошибки (stderr ffmpeg/ffprobe и т.д.); для буфера приоритетнее краткого <see cref="ErrorMessage"/>.</summary>
|
||
public string? ErrorDetails
|
||
{
|
||
get => _errorDetails;
|
||
set
|
||
{
|
||
if (_errorDetails == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_errorDetails = value;
|
||
OnPropertyChanged();
|
||
}
|
||
}
|
||
|
||
public event PropertyChangedEventHandler? PropertyChanged;
|
||
|
||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||
{
|
||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||
}
|
||
|
||
private void RecomputeSkipFlag()
|
||
{
|
||
IsSkipPlan = LastPlan?.HasRealActions is false;
|
||
}
|
||
|
||
public void UpdateOutputPath(string newPath, string? newContainerFormat)
|
||
{
|
||
var normalized = Path.GetFullPath(newPath);
|
||
FullPath = normalized;
|
||
FileName = Path.GetFileName(normalized);
|
||
DirectoryPath = Path.GetDirectoryName(normalized) ?? string.Empty;
|
||
SetInitialFileSizeBytes();
|
||
if (MediaAnalysis is { } m)
|
||
{
|
||
MediaAnalysis = new MediaAnalysisResult
|
||
{
|
||
ContainerFormat = newContainerFormat ?? m.ContainerFormat,
|
||
FormatName = m.FormatName,
|
||
FormatBitRateBps = m.FormatBitRateBps,
|
||
DurationSeconds = m.DurationSeconds,
|
||
VideoStreams = m.VideoStreams,
|
||
AudioStreams = m.AudioStreams,
|
||
SubtitleStreams = m.SubtitleStreams,
|
||
DataStreams = m.DataStreams,
|
||
AllStreams = m.AllStreams,
|
||
SourceVideoBitrateBps = m.SourceVideoBitrateBps
|
||
};
|
||
OnPropertyChanged(nameof(TrackSummaryDisplay));
|
||
}
|
||
}
|
||
}
|