849 lines
30 KiB
C#
849 lines
30 KiB
C#
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);
|
||
}
|