emby-toolbox/EmbyToolbox/Services/ConversionPlanService.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

849 lines
30 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// <summary>Строит план сравнения с профилем: без вызова ffmpeg.</summary>
public sealed class ConversionPlanService
{
/// <summary>Встроенная аудиодорожка уже соответствует целевому аудиокодеку профиля (AAC и т.д.).</summary>
public static bool EmbeddedAudioMatchesProfile(string? fileCodec, ConversionProfileSettingsEntry profile) =>
AudioCodecMatchesAgainstLabel(fileCodec, profile.Audio);
/// <summary>Сравнение кодека файла с подписью цели (как в snapshot), без полного профиля.</summary>
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<SidecarFile> sidecars,
ConversionProfileSettingsEntry profile,
ConversionTaskOverride? ovr,
IReadOnlyList<ExternalAudioFile>? externalAudioForBaseline = null)
{
var steps = new List<string>();
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<string> s, string sub) => s.Any(x => x.Contains(sub, StringComparison.OrdinalIgnoreCase));
private static string BuildCountSummary(
ConversionProfileSettingsEntry p,
MediaAnalysisResult m,
IReadOnlyList<string> steps,
ConversionPlanActionStats stats,
ConversionTaskOverride? ovr,
int plannedFontAdds,
bool hasRealActions)
{
var parts = new List<string>();
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<string> steps,
ConversionPlanActionStats stats,
MediaAnalysisResult media,
IReadOnlyList<SidecarFile> sidecars,
ConversionProfileSettingsEntry profile,
ConversionTaskOverride? ovr,
IReadOnlyList<ExternalAudioFile>? 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<SidecarFile> sidecars,
ConversionProfileSettingsEntry profile,
ConversionTaskOverride? ovr,
IReadOnlyList<ExternalAudioFile>? 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<ConversionTrackPlan> BuildTrackParts(ConversionTaskOverride? ovr, string container, MediaAnalysisResult? media)
{
if (ovr is null || ovr.TrackOverrides.Count == 0)
{
return [];
}
var supportsAttachments = SupportsAttachments(container);
var list = new List<ConversionTrackPlan>(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<string> 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);
}