using System.Collections.Generic; using System.Globalization; using System.Linq; using EmbyToolbox.Models; namespace EmbyToolbox.Services; /// Строит план сравнения с профилем: без вызова ffmpeg. public sealed class ConversionPlanService { /// Встроенная аудиодорожка уже соответствует целевому аудиокодеку профиля (AAC и т.д.). public static bool EmbeddedAudioMatchesProfile(string? fileCodec, ConversionProfileSettingsEntry profile) => AudioCodecMatchesAgainstLabel(fileCodec, profile.Audio); /// Сравнение кодека файла с подписью цели (как в snapshot), без полного профиля. public static bool VideoCodecMatchesTarget(string? fileCodec, string targetVideoLabel) => VideoCodecMatches(fileCodec, targetVideoLabel); public static bool AudioCodecMatchesTarget(string? fileCodec, string targetAudioLabel) => AudioCodecMatchesAgainstLabel(fileCodec, targetAudioLabel); public ConversionPlan Build( MediaAnalysisResult media, IReadOnlyList sidecars, ConversionProfileSettingsEntry profile, ConversionTaskOverride? ovr, IReadOnlyList? externalAudioForBaseline = null) { var steps = new List(); var p = MergeProfile(profile, ovr); var v = media.PrimaryVideo; var cont = (media.ContainerFormat ?? string.Empty).ToLowerInvariant(); var requiresTimestampFix = MpegTsTimestampHelpers.IsMpegTsInput(media, null) && WantsMkv(p) && !IsMkvContainer(cont); if (WantsMkv(p) && !IsMkvContainer(cont)) { steps.Add("Remux to MKV"); } if (requiresTimestampFix) { steps.Add("MPEG-TS: исправление меток времени (genpts / mux; при сбое — перекодирование видео)"); } else if (WantsMp4(p) && !IsMp4ish(cont) && !HasStep(steps, "Remux")) { steps.Add("Remux to MP4"); } if (v is not null) { if (!VideoCodecMatches(v.CodecName, p.Video)) { steps.Add("Convert video to " + p.Video); } if (!string.IsNullOrEmpty(p.PixelFormat) && !IsUnchanged(p.PixelFormat) && !StringEqualsLoose(v.PixelFormat, ToPixNorm(p.PixelFormat))) { steps.Add("Convert pixel format to " + p.PixelFormat); } if (!string.IsNullOrEmpty(p.Resolution) && !IsUnchanged(p.Resolution)) { if (v.Width is { } w && v.Height is { } h) { if (!ResMatchesFile(w, h, p.Resolution)) { steps.Add("Resolution: " + p.Resolution); } } } if (!string.IsNullOrEmpty(p.Fps) && !IsUnchanged(p.Fps) && v.FrameRate is { } f) { if (TryParseMaxValue(p.Fps) is { } fpsMax) { if (f > fpsMax + FpsEpsilon) { steps.Add($"FPS: максимум {FormatNumber(fpsMax)}"); } } } } var targetVideoBitrateKbps = VideoBitratePolicy.ResolveTargetKbps( p.VideoBitrateMode, p.VideoBitrateMbps, media); var normalizedVideoBitrateMode = VideoBitratePolicy.NormalizeMode(p.VideoBitrateMode); var videoWillTranscode = RequiresVideoTranscode(media, profile, ovr); if (videoWillTranscode) { var bitrateStep = BuildVideoBitrateStep(normalizedVideoBitrateMode, targetVideoBitrateKbps); if (!string.IsNullOrWhiteSpace(bitrateStep)) { steps.Add(bitrateStep); } } var hasOverrideList = ovr is { TrackOverrides.Count: > 0 }; if (hasOverrideList && ovr is not null) { AppendSubtitlePlanSteps(media, ovr, p, steps); } if (hasOverrideList && ovr!.TrackOverrides.Any(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Audio, Action: TrackActionKind.Convert })) { if (!string.IsNullOrEmpty(p.Audio) && !IsUnchanged(p.Audio)) { steps.Add("Convert audio to " + p.Audio + " " + p.Bitrate); } } else if (!hasOverrideList) { if (!string.IsNullOrEmpty(p.Audio) && !IsUnchanged(p.Audio)) { var want = p.Audio; if (media.AudioStreams.Any() && !media.AudioStreams.All(a => AudioCodecMatchesAgainstLabel(a.CodecName, p.Audio))) { steps.Add("Convert audio to " + p.Audio + " " + p.Bitrate); } } } if (p.Subtitles is "Нет" or "No" or "false") { if (media.SubtitleStreams.Count > 0) { steps.Add("Remove non-RUS subtitles"); } } else { if (ProfileRemovesNonRusSubs(p) && media.SubtitleStreams.Count > 0) { steps.Add("Remove non-RUS subtitles"); } } if (p.ExternalSubtitles is "Да" or "Yes" or "true") { if (sidecars.Any(s => s.IsSubtitle)) { steps.Add("Add external subtitles rus default"); } } if (p.ExternalTracks is "Да" or "Yes" or "true") { var externalAudioAdds = ovr?.TrackOverrides .Where(t => t is { Source: SourceKind.External, StreamKind: MediaStreamKind.Audio, Action: TrackActionKind.Add }) .ToList(); if (externalAudioAdds is { Count: > 0 }) { if (externalAudioAdds.All(t => IsAacCodec(t.ExternalStreamCodec))) { steps.Add("Add external audio AAC copy"); } else { steps.Add("Add external audio -> AAC 256 kbps"); } } else if (sidecars.Any(s => s.IsAudio)) { steps.Add("Add external audio rus default"); } } var plannedFontAdds = ovr?.TrackOverrides.Count( t => t is { Source: SourceKind.External, StreamKind: MediaStreamKind.Attachment, Action: TrackActionKind.Add }) ?? 0; if (plannedFontAdds > 0) { if (SupportsAttachments(p.Container)) { steps.Add("Add external fonts attachments"); } else { steps.Add("Fonts skipped: container does not support attachments"); } } if (media.DataStreams.Count > 0) { steps.Add("Remove data streams"); } var stats = ConversionPlanActionStats.FromOverrides(ovr); var hasRealActions = HasRealActions(steps, stats, media, sidecars, profile, ovr, externalAudioForBaseline); var shortSummary = BuildCountSummary(p, media, steps, stats, ovr, plannedFontAdds, hasRealActions); var trackParts = BuildTrackParts(ovr, p.Container, media); if (!hasRealActions) { return new ConversionPlan { SuggestsSkip = true, HasRealActions = false, StepDescriptions = new[] { "Skip — обработка не требуется" }, ActionStats = stats, TrackParts = trackParts, ShortSummary = "Skip — обработка не требуется", TargetVideoBitrateMode = normalizedVideoBitrateMode, TargetVideoBitrateKbps = targetVideoBitrateKbps, RequiresTimestampFix = requiresTimestampFix }; } return new ConversionPlan { SuggestsSkip = false, HasRealActions = true, StepDescriptions = steps, ActionStats = stats, TrackParts = trackParts, ShortSummary = shortSummary, TargetVideoBitrateMode = normalizedVideoBitrateMode, TargetVideoBitrateKbps = targetVideoBitrateKbps, RequiresTimestampFix = requiresTimestampFix }; } public static bool RequiresVideoTranscode( MediaAnalysisResult media, ConversionProfileSettingsEntry profile, ConversionTaskOverride? ovr) { var v = media.PrimaryVideo; if (v is null) { return false; } var p = MergeProfile(profile, ovr); if (!VideoCodecMatches(v.CodecName, p.Video)) { return true; } if (!string.IsNullOrEmpty(p.PixelFormat) && !IsUnchanged(p.PixelFormat) && !StringEqualsLoose(v.PixelFormat, ToPixNorm(p.PixelFormat))) { return true; } if (!string.IsNullOrEmpty(p.Resolution) && !IsUnchanged(p.Resolution)) { if (v.Width is { } w && v.Height is { } h && !ResMatchesFile(w, h, p.Resolution)) { return true; } } if (!string.IsNullOrEmpty(p.Fps) && !IsUnchanged(p.Fps) && v.FrameRate is { } f) { if (TryParseMaxValue(p.Fps) is { } fpsMax && f > fpsMax + FpsEpsilon) { return true; } } var normalizedVideoBitrateMode = VideoBitratePolicy.NormalizeMode(p.VideoBitrateMode); if (normalizedVideoBitrateMode == VideoBitratePolicy.Auto) { return false; } if (normalizedVideoBitrateMode == VideoBitratePolicy.Source && media.SourceVideoBitrateBps is null) { return false; } var targetVideoLabel = p.Video ?? string.Empty; if (targetVideoLabel.Contains("copy", StringComparison.OrdinalIgnoreCase)) { return false; } return true; } private static bool HasStep(IReadOnlyList s, string sub) => s.Any(x => x.Contains(sub, StringComparison.OrdinalIgnoreCase)); private static string BuildCountSummary( ConversionProfileSettingsEntry p, MediaAnalysisResult m, IReadOnlyList steps, ConversionPlanActionStats stats, ConversionTaskOverride? ovr, int plannedFontAdds, bool hasRealActions) { var parts = new List(); foreach (var step in steps.Where(s => !string.IsNullOrWhiteSpace(s))) { parts.Add(step); } var addSub = 0; var addAudio = 0; var addFonts = 0; var removeAudio = 0; var removeSubtitle = 0; var removeOther = 0; if (ovr is { TrackOverrides.Count: > 0 }) { addSub = ovr.TrackOverrides.Count( t => t is { Source: SourceKind.External, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Add }); addAudio = ovr.TrackOverrides.Count( t => t is { Source: SourceKind.External, StreamKind: MediaStreamKind.Audio, Action: TrackActionKind.Add }); addFonts = ovr.TrackOverrides.Count( t => t is { Source: SourceKind.External, StreamKind: MediaStreamKind.Attachment, Action: TrackActionKind.Add }); removeAudio = ovr.TrackOverrides.Count( t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Audio, Action: TrackActionKind.Remove }); removeSubtitle = ovr.TrackOverrides.Count( t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Remove }); removeOther = ovr.TrackOverrides.Count( t => t is { Source: SourceKind.Embedded, Action: TrackActionKind.Remove } && t.StreamKind is not MediaStreamKind.Audio && t.StreamKind is not MediaStreamKind.Subtitle); } if (addSub > 0) { parts.Add("Add external subtitle: " + addSub); } if (addAudio > 0) { parts.Add("Add external audio: " + addAudio); } if (plannedFontAdds > 0) { if (SupportsAttachments(p.Container)) { parts.Add("Add fonts: " + addFonts); } } if (removeSubtitle > 0) { parts.Add("Remove subtitle: " + removeSubtitle); } if (removeAudio > 0) { parts.Add("Remove audio: " + removeAudio); } if (removeOther > 0) { parts.Add("Remove tracks: " + removeOther); } if (stats.ConvertAudio > 0) { parts.Add("Convert audio: " + stats.ConvertAudio); } if (!hasRealActions) { return "Skip — обработка не требуется"; } var summary = string.Join(" | ", parts .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase)); return string.IsNullOrWhiteSpace(summary) ? "Track metadata changed" : summary; } private static bool HasRealActions( IReadOnlyList steps, ConversionPlanActionStats stats, MediaAnalysisResult media, IReadOnlyList sidecars, ConversionProfileSettingsEntry profile, ConversionTaskOverride? ovr, IReadOnlyList? externalAudioForBaseline) { if (steps.Count > 0) { return true; } if (stats is { Add: > 0 } or { Remove: > 0 } or { ConvertAudio: > 0 } or { ConvertVideo: > 0 } or { SubtitleConvert: > 0 } or { SubtitleRemove: > 0 }) { return true; } if (HasTrackOverrideChanges(media, sidecars, profile, ovr, externalAudioForBaseline)) { return true; } return false; } private static bool HasTrackOverrideChanges( MediaAnalysisResult media, IReadOnlyList sidecars, ConversionProfileSettingsEntry profile, ConversionTaskOverride? ovr, IReadOnlyList? externalAudioForBaseline) { if (ovr is null) { return false; } var baseline = new ConversionTaskOverride(); TrackOverrideSeeder.EnsureDefaults(baseline, media, sidecars, profile, externalAudio: externalAudioForBaseline); return !OverridesEquivalent(ovr, baseline); } 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 string? BuildVideoBitrateStep(string normalizedMode, int? targetVideoBitrateKbps) { if (normalizedMode == VideoBitratePolicy.Auto) { return null; } if (normalizedMode == VideoBitratePolicy.Source) { return "Video bitrate: source"; } if (targetVideoBitrateKbps is not { } bitrateKbps || bitrateKbps <= 0) { return null; } return $"Video bitrate: {bitrateKbps / 1000.0:0.###} Mbps"; } private static string DescribeTrackPlanLine(TrackOverrideEntry t, bool supportsAttachments, MediaAnalysisResult? media) { if (t.StreamKind == MediaStreamKind.Attachment && t.Action == TrackActionKind.Add && !supportsAttachments) { return "Fonts skipped: container does not support attachments"; } if (t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Remove }) { var codec = media?.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName; return SubtitleCodecRules.IsTeletext(codec) ? "Удалить teletext subtitle" : "Remove subtitle: 1"; } return $"{t.Action} {t.StreamKind}"; } private static IReadOnlyList BuildTrackParts(ConversionTaskOverride? ovr, string container, MediaAnalysisResult? media) { if (ovr is null || ovr.TrackOverrides.Count == 0) { return []; } var supportsAttachments = SupportsAttachments(container); var list = new List(ovr.TrackOverrides.Count); foreach (var t in ovr.TrackOverrides) { var action = t.Action switch { TrackActionKind.Add => t.StreamKind == MediaStreamKind.Attachment && !supportsAttachments ? ConversionPlanAction.None : t.StreamKind == MediaStreamKind.Subtitle ? ConversionPlanAction.AddExternalSubtitles : ConversionPlanAction.AddExternalAudio, TrackActionKind.Remove => t.StreamKind == MediaStreamKind.Subtitle ? ConversionPlanAction.RemoveNonRusSubtitles : ConversionPlanAction.RemoveDataStreams, TrackActionKind.Convert => t.StreamKind == MediaStreamKind.Audio ? ConversionPlanAction.ConvertAudio : t.StreamKind == MediaStreamKind.Video ? ConversionPlanAction.ConvertVideo : ConversionPlanAction.None, _ => ConversionPlanAction.None }; var desc = DescribeTrackPlanLine(t, supportsAttachments, media); list.Add( new ConversionTrackPlan { StreamIndex = t.StreamIndex, Source = t.Source, StreamKind = t.StreamKind, Action = action, Description = desc }); } return list; } private static bool WantsMkv(ConversionProfileSettingsEntry p) => p.Container.Equals("MKV", StringComparison.OrdinalIgnoreCase) || p.Container.Contains("Matro", StringComparison.OrdinalIgnoreCase); private static bool WantsMp4(ConversionProfileSettingsEntry p) => p.Container.Equals("MP4", StringComparison.OrdinalIgnoreCase) || p.Container.Equals("M4V", StringComparison.OrdinalIgnoreCase); private static bool SupportsAttachments(string? c) => !string.IsNullOrWhiteSpace(c) && (c.Contains("mkv", StringComparison.OrdinalIgnoreCase) || c.Contains("matro", StringComparison.OrdinalIgnoreCase)); private static bool IsMkvContainer(string c) => c.Contains("matro", StringComparison.Ordinal) || c.Contains("mkv", StringComparison.Ordinal) || c.Contains("webm", StringComparison.Ordinal); private static bool IsMp4ish(string c) => c.Contains("mp4", StringComparison.Ordinal) || c.Contains("mov", StringComparison.Ordinal) || c.Contains("isom", StringComparison.Ordinal); private static bool IsUnchanged(string? s) => string.IsNullOrWhiteSpace(s) || s.Contains("без", StringComparison.OrdinalIgnoreCase) || s.Contains("No change", StringComparison.OrdinalIgnoreCase); private static string ToPixNorm(string p) => p.Replace(" ", string.Empty, StringComparison.Ordinal); private static ConversionProfileSettingsEntry MergeProfile(ConversionProfileSettingsEntry profile, ConversionTaskOverride? ovr) { if (ovr is null) { return profile; } static string Coa(string? a, string b) => string.IsNullOrWhiteSpace(a) ? b : a!.Trim(); return profile with { Container = Coa(ovr.TargetContainer, profile.Container), Video = Coa(ovr.TargetVideo, profile.Video), PixelFormat = Coa(ovr.TargetPixelFormat, profile.PixelFormat), Resolution = Coa(ovr.TargetResolution, profile.Resolution), Fps = Coa(ovr.TargetFps, profile.Fps), Bitrate = Coa(ovr.TargetAudioBitrate, profile.Bitrate), VideoBitrateMode = Coa(ovr.TargetVideoBitrateMode, profile.VideoBitrateMode), VideoBitrateMbps = ovr.TargetVideoBitrateMbps > 0 ? ovr.TargetVideoBitrateMbps : profile.VideoBitrateMbps }; } private static bool StringEqualsLoose(string? a, string? b) { if (a is null || b is null) { return false; } return string.Equals(a, b, StringComparison.OrdinalIgnoreCase) || a.Replace(" ", string.Empty, StringComparison.Ordinal).Equals(b.Replace(" ", string.Empty, StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase); } private static bool VideoCodecMatches(string? fileCodec, string profileVideo) { if (string.IsNullOrEmpty(fileCodec)) { return false; } if (profileVideo.Contains("Copy", StringComparison.OrdinalIgnoreCase)) { return true; } var f = fileCodec.ToLowerInvariant(); if (IsUnchanged(profileVideo)) { return true; } if (profileVideo.Contains("H.264", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("H264", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("avc", StringComparison.OrdinalIgnoreCase)) { return f.Contains("h264", StringComparison.Ordinal) || f.Contains("avc", StringComparison.Ordinal) || f == "h264" || f == "h.264"; } if (profileVideo.Contains("H.265", StringComparison.OrdinalIgnoreCase) || profileVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase)) { return f.Contains("h265", StringComparison.Ordinal) || f.Contains("hevc", StringComparison.Ordinal); } return f.Contains(profileVideo, StringComparison.OrdinalIgnoreCase); } private static bool AudioCodecMatchesAgainstLabel(string? fileCodec, string profileAudio) { if (string.IsNullOrEmpty(fileCodec)) { return true; } if (profileAudio.Contains("Copy", StringComparison.OrdinalIgnoreCase)) { return true; } if (IsUnchanged(profileAudio)) { return true; } var f = fileCodec.ToLowerInvariant(); if (profileAudio.Contains("AAC", StringComparison.OrdinalIgnoreCase)) { return f.Contains("aac", StringComparison.Ordinal) || f.Contains("fdk", StringComparison.Ordinal); } if (profileAudio.Contains("AC-3", StringComparison.Ordinal) || profileAudio.Contains("AC3", StringComparison.OrdinalIgnoreCase)) { return f.Contains("ac3", StringComparison.Ordinal) || f == "eac3"; } if (profileAudio.Contains("EAC3", StringComparison.OrdinalIgnoreCase) || profileAudio.Contains("E-AC-3", StringComparison.OrdinalIgnoreCase)) { return f.Contains("eac3", StringComparison.Ordinal) || f.Contains("ac3", StringComparison.Ordinal); } if (profileAudio.Contains("Opus", StringComparison.OrdinalIgnoreCase)) { return f.Contains("opus", StringComparison.Ordinal); } if (profileAudio.Contains("MP3", StringComparison.OrdinalIgnoreCase)) { return f.Contains("mp3", StringComparison.Ordinal); } if (profileAudio.Contains("FLAC", StringComparison.OrdinalIgnoreCase)) { return f.Contains("flac", StringComparison.Ordinal); } return f.Contains(profileAudio, StringComparison.OrdinalIgnoreCase); } private static bool ResMatchesFile(int w, int h, string pRes) { if (TryParseMaxValue(pRes) is { } maxRes) { // "Максимум 1080p": ограничиваем вертикальное измерение (короткая сторона кадра). var sourceVertical = System.Math.Min(w, h); return sourceVertical <= maxRes; } if (pRes.Contains("1080", StringComparison.Ordinal)) { return w == 1920 && h == 1080; } if (pRes.Contains("720", StringComparison.Ordinal)) { return w == 1280 && h == 720; } if (pRes.Contains("2160", StringComparison.Ordinal) || pRes.Contains("4K", StringComparison.OrdinalIgnoreCase)) { return w == 3840 && h == 2160; } var m = pRes.Split('x', 'X'); if (m.Length == 2 && int.TryParse(m[0], out var pw) && int.TryParse(m[1], out var ph)) { return w == pw && h == ph; } return true; } private static double? TryParseMaxValue(string s) { if (string.IsNullOrWhiteSpace(s)) { return null; } if (double.TryParse(s.Replace(',', '.').Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) { return d; } var normalized = s.Trim(); var parts = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); for (var i = parts.Length - 1; i >= 0; i--) { var token = parts[i] .Replace("p", string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("fps", string.Empty, StringComparison.OrdinalIgnoreCase) .Trim(); if (double.TryParse(token.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out d)) { return d; } } return null; } private static string FormatNumber(double value) => value % 1d == 0d ? ((int)value).ToString(CultureInfo.InvariantCulture) : value.ToString("0.###", CultureInfo.InvariantCulture); private const double FpsEpsilon = 0.01; private static void AppendSubtitlePlanSteps(MediaAnalysisResult media, ConversionTaskOverride ovr, ConversionProfileSettingsEntry profile, List steps) { var effective = string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer; var removed = ovr.TrackOverrides .Where(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle, Action: TrackActionKind.Remove }) .ToList(); if (removed.Count > 0) { var tel = 0; var other = 0; foreach (var t in removed) { var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName; if (SubtitleCodecRules.IsTeletext(codec)) { tel++; } else { other++; } } if (tel > 0) { steps.Add("Удалить teletext subtitle"); } if (other > 0) { steps.Add($"Remove subtitle: {other}"); } } if (!SubtitleCodecRules.TargetsMp4(effective)) { return; } var transcodeSubs = ovr.TrackOverrides .Where(t => t is { Source: SourceKind.Embedded, StreamKind: MediaStreamKind.Subtitle } && t.Action is TrackActionKind.Convert or TrackActionKind.Keep) .Where(t => { var codec = media.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex)?.CodecName; return SubtitleCodecRules.Mp4RequiresSubtitleTranscode(codec); }).ToList(); if (transcodeSubs.Count > 0) { steps.Add("Subtitle -> mov_text (MP4)"); } } private static bool ProfileRemovesNonRusSubs(ConversionProfileSettingsEntry p) => p.Subtitles is "только" or "RUS" or "rus" || p.Profile.Contains("rus", StringComparison.OrdinalIgnoreCase); private static bool IsAacCodec(string? codecName) => !string.IsNullOrWhiteSpace(codecName) && codecName.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase); }