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);
}