505 lines
19 KiB
C#
505 lines
19 KiB
C#
using System.Globalization;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using EmbyToolbox.Models;
|
||
|
||
namespace EmbyToolbox.Services;
|
||
|
||
/// <summary>Заполняет <see cref="ConversionTaskOverride"/> значениями по умолчанию.</summary>
|
||
public static class TrackOverrideSeeder
|
||
{
|
||
public static void EnsureDefaults(
|
||
ConversionTaskOverride o,
|
||
MediaAnalysisResult? media,
|
||
IReadOnlyList<SidecarFile> side,
|
||
ConversionProfileSettingsEntry profile,
|
||
bool autoRemoveForeignTracks = false,
|
||
IReadOnlyList<ExternalAudioFile>? externalAudio = null,
|
||
string? videoPath = null,
|
||
SidecarTitleResolver? sidecarTitleResolver = null,
|
||
LoggingService? logging = null,
|
||
bool disableSubtitleDefault = false)
|
||
{
|
||
if (o.TrackOverrides.Count > 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
SyncTargetFieldsFromProfile(o, profile);
|
||
|
||
if (media is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var requiresVideoTranscode = ConversionPlanService.RequiresVideoTranscode(media, profile, o);
|
||
var primaryVideoIndex = media.PrimaryVideo?.Index;
|
||
foreach (var s in media.AllStreams)
|
||
{
|
||
var a = s.Kind == MediaStreamKind.Data ? TrackActionKind.Remove
|
||
: s.Kind == MediaStreamKind.Attachment ? TrackActionKind.Keep
|
||
: s.Kind == MediaStreamKind.Video
|
||
? (primaryVideoIndex is { } pv && s.Index != pv
|
||
? TrackActionKind.Remove
|
||
: (requiresVideoTranscode && primaryVideoIndex is { } pv2 && s.Index == pv2
|
||
? TrackActionKind.Convert
|
||
: TrackActionKind.Keep))
|
||
: s.Kind == MediaStreamKind.Audio
|
||
? (ConversionPlanService.EmbeddedAudioMatchesProfile(s.CodecName, profile) ? TrackActionKind.Keep : TrackActionKind.Convert)
|
||
: s.Kind == MediaStreamKind.Subtitle
|
||
? SubtitleCodecRules.DefaultEmbeddedSubtitleAction(s.CodecName, profile.Container)
|
||
: TrackActionKind.Keep;
|
||
if (autoRemoveForeignTracks
|
||
&& s.Kind is MediaStreamKind.Audio or MediaStreamKind.Subtitle
|
||
&& IsForeignLanguageForAutoRemove(s.Language))
|
||
{
|
||
a = TrackActionKind.Remove;
|
||
}
|
||
|
||
o.TrackOverrides.Add(
|
||
new TrackOverrideEntry
|
||
{
|
||
StreamIndex = s.Index,
|
||
Source = SourceKind.Embedded,
|
||
StreamKind = s.Kind,
|
||
Action = a,
|
||
Default = s.Kind is MediaStreamKind.Audio or MediaStreamKind.Subtitle ? s.IsDefault : null,
|
||
Language = s.Language,
|
||
Title = s.Title,
|
||
AudioBitrateKbps = s.Kind == MediaStreamKind.Audio && a == TrackActionKind.Convert ? "256 kbps" : null
|
||
});
|
||
}
|
||
|
||
var synthIndex = -1000;
|
||
|
||
var supportsAttachments = SupportsAttachments(profile.Container);
|
||
var titleResolver = sidecarTitleResolver ?? new SidecarTitleResolver();
|
||
var effectiveVideoPath = string.IsNullOrWhiteSpace(videoPath) ? InferVideoPathFromSidecars(side) : videoPath!;
|
||
var subtitleSidecars = side.Where(s => s.IsSubtitle).ToList();
|
||
foreach (var sc in side)
|
||
{
|
||
if (sc.IsAudio)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var streamKind = sc.IsFont ? MediaStreamKind.Attachment : MediaStreamKind.Subtitle;
|
||
string? externalTitle;
|
||
if (streamKind == MediaStreamKind.Subtitle)
|
||
{
|
||
var ord = subtitleSidecars.FindIndex(s =>
|
||
string.Equals(s.FullPath, sc.FullPath, StringComparison.OrdinalIgnoreCase))
|
||
+ 1;
|
||
var subtitleIndexForFallback = subtitleSidecars.Count > 1 ? ord : 0;
|
||
externalTitle = titleResolver.ResolveExternalSubtitleTitle(
|
||
effectiveVideoPath,
|
||
sc.FullPath,
|
||
subtitleIndexForFallback);
|
||
LogRecognizedOrFallback(logging, externalTitle);
|
||
}
|
||
else
|
||
{
|
||
externalTitle = Path.GetFileName(sc.FullPath);
|
||
}
|
||
|
||
o.TrackOverrides.Add(
|
||
new TrackOverrideEntry
|
||
{
|
||
StreamIndex = synthIndex--,
|
||
ExternalPath = sc.FullPath,
|
||
Source = SourceKind.External,
|
||
StreamKind = streamKind,
|
||
Action = streamKind == MediaStreamKind.Attachment && !supportsAttachments ? TrackActionKind.Remove : TrackActionKind.Add,
|
||
Default = streamKind == MediaStreamKind.Attachment ? null : true,
|
||
Language = streamKind == MediaStreamKind.Attachment ? null : "rus",
|
||
Title = externalTitle
|
||
});
|
||
}
|
||
|
||
var resolvedAudio = ResolveExternalAudioFiles(side, externalAudio);
|
||
var flattened = new List<(ExternalAudioFile file, ExternalAudioStream st)>();
|
||
foreach (var audioFile in resolvedAudio)
|
||
{
|
||
foreach (var st in audioFile.Streams)
|
||
{
|
||
flattened.Add((audioFile, st));
|
||
}
|
||
}
|
||
|
||
var totalExtStreams = flattened.Count;
|
||
var firstExternalAudioSet = false;
|
||
var sameFileCounters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
var commonTitleByFile = BuildCommonTitleByExternalAudioFile(flattened, effectiveVideoPath, titleResolver);
|
||
for (var flatIdx = 0; flatIdx < flattened.Count; flatIdx++)
|
||
{
|
||
var audioFile = flattened[flatIdx].file;
|
||
var st = flattened[flatIdx].st;
|
||
var isFirstExternalAudio = !firstExternalAudioSet;
|
||
if (isFirstExternalAudio)
|
||
{
|
||
firstExternalAudioSet = true;
|
||
}
|
||
|
||
var oneBasedOrdinalAmongAllExternals = flatIdx + 1;
|
||
var details = FormatExternalAudioDetails(st);
|
||
var tagTitle = SidecarTitleResolver.NormalizeTitleOrNull(st.TitleFromProbe);
|
||
var commonTitle = commonTitleByFile.GetValueOrDefault(audioFile.FullPath);
|
||
|
||
string displayTitle;
|
||
if (tagTitle is not null)
|
||
{
|
||
displayTitle = tagTitle;
|
||
LogRecognizedOrFallback(logging, displayTitle);
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(commonTitle))
|
||
{
|
||
var sameFileOrdinal = sameFileCounters.TryGetValue(audioFile.FullPath, out var current)
|
||
? current + 1
|
||
: 1;
|
||
sameFileCounters[audioFile.FullPath] = sameFileOrdinal;
|
||
|
||
displayTitle = audioFile.Streams.Count > 1
|
||
? $"{commonTitle} {sameFileOrdinal}"
|
||
: commonTitle!;
|
||
LogRecognizedOrFallback(logging, displayTitle);
|
||
}
|
||
else
|
||
{
|
||
displayTitle = titleResolver.ResolveExternalAudioTitle(
|
||
effectiveVideoPath,
|
||
audioFile.FullPath,
|
||
oneBasedOrdinalAmongAllExternals,
|
||
streamTags: null);
|
||
LogRecognizedOrFallback(logging, displayTitle);
|
||
}
|
||
|
||
o.TrackOverrides.Add(
|
||
new TrackOverrideEntry
|
||
{
|
||
StreamIndex = synthIndex--,
|
||
ExternalPath = audioFile.FullPath,
|
||
ExternalAudioStreamOrdinal = st.StreamOrdinal,
|
||
SameFileExternalAudioStreamCount = audioFile.Streams.Count,
|
||
ExternalStreamCodec = st.CodecName,
|
||
ExternalStreamDetails = details,
|
||
ExternalFfprobeTitle = tagTitle,
|
||
Source = SourceKind.External,
|
||
StreamKind = MediaStreamKind.Audio,
|
||
Action = TrackActionKind.Add,
|
||
Default = isFirstExternalAudio,
|
||
Language = "rus",
|
||
Title = displayTitle,
|
||
AudioBitrateKbps = IsAacCodecName(st.CodecName) ? null : "256 kbps"
|
||
});
|
||
}
|
||
|
||
ApplySubtitleDefaultRules(o, media, logging, disableSubtitleDefault);
|
||
}
|
||
|
||
/// <summary>RUS (один файл) или RUS 1, RUS 2, … (порядок как в <paramref name="sidecarSubtitleList"/>).</summary>
|
||
public static string BuildExternalSubtitleDisplayTitle(int oneBasedIndex, int totalExternalSubtitles)
|
||
{
|
||
if (totalExternalSubtitles <= 0 || oneBasedIndex < 1)
|
||
{
|
||
return "RUS";
|
||
}
|
||
|
||
return totalExternalSubtitles == 1 ? "RUS" : $"RUS {oneBasedIndex}";
|
||
}
|
||
|
||
/// <summary>Каноническое title для внешних субтитров: распознанное имя либо fallback RUS / RUS N.</summary>
|
||
public static string ExternalSubtitleCanonicalTitle(
|
||
IReadOnlyList<TrackOverrideEntry> trackOrder,
|
||
TrackOverrideEntry entry,
|
||
string videoPath,
|
||
SidecarTitleResolver? sidecarTitleResolver = null)
|
||
{
|
||
if (entry.Source != SourceKind.External
|
||
|| entry.StreamKind != MediaStreamKind.Subtitle
|
||
|| string.IsNullOrWhiteSpace(entry.ExternalPath))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var externalSubs = trackOrder
|
||
.Where(t => t.Source == SourceKind.External
|
||
&& t.StreamKind == MediaStreamKind.Subtitle
|
||
&& !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
.ToList();
|
||
|
||
var idx = externalSubs.FindIndex(t =>
|
||
string.Equals(t.ExternalPath, entry.ExternalPath, StringComparison.OrdinalIgnoreCase));
|
||
|
||
var resolver = sidecarTitleResolver ?? new SidecarTitleResolver();
|
||
var oneBased = idx >= 0 ? idx + 1 : 1;
|
||
var subtitleIndexForFallback = externalSubs.Count > 1 ? oneBased : 0;
|
||
return resolver.ResolveExternalSubtitleTitle(videoPath, entry.ExternalPath!, subtitleIndexForFallback);
|
||
}
|
||
|
||
public static string ExternalAudioCanonicalTitleFromEntry(
|
||
IReadOnlyList<TrackOverrideEntry> orderedTracks,
|
||
TrackOverrideEntry entry,
|
||
string videoPath,
|
||
SidecarTitleResolver? sidecarTitleResolver = null)
|
||
{
|
||
if (entry.Source != SourceKind.External || entry.StreamKind != MediaStreamKind.Audio || string.IsNullOrWhiteSpace(entry.ExternalPath))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var list = orderedTracks
|
||
.Where(t =>
|
||
t.Source == SourceKind.External
|
||
&& t.StreamKind == MediaStreamKind.Audio
|
||
&& !string.IsNullOrWhiteSpace(t.ExternalPath))
|
||
.ToList();
|
||
|
||
var idx = list.FindIndex(t => ReferenceEquals(t, entry));
|
||
if (idx < 0)
|
||
{
|
||
idx = list.FindIndex(
|
||
t => string.Equals(t.ExternalPath, entry.ExternalPath, StringComparison.OrdinalIgnoreCase)
|
||
&& t.ExternalAudioStreamOrdinal == entry.ExternalAudioStreamOrdinal);
|
||
}
|
||
|
||
var oneBased = idx >= 0 ? idx + 1 : 1;
|
||
var tag = SidecarTitleResolver.NormalizeTitleOrNull(entry.ExternalFfprobeTitle);
|
||
if (tag is not null)
|
||
{
|
||
return tag;
|
||
}
|
||
|
||
var resolver = sidecarTitleResolver ?? new SidecarTitleResolver();
|
||
return resolver.ResolveExternalAudioTitle(videoPath, entry.ExternalPath!, oneBased, streamTags: null);
|
||
}
|
||
|
||
private static IReadOnlyList<ExternalAudioFile> ResolveExternalAudioFiles(
|
||
IReadOnlyList<SidecarFile> side,
|
||
IReadOnlyList<ExternalAudioFile>? supplied)
|
||
{
|
||
if (supplied is { Count: > 0 })
|
||
{
|
||
return supplied;
|
||
}
|
||
|
||
var paths = side.Where(s => s.IsAudio).Select(s => s.FullPath)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
List<ExternalAudioFile> fallback = [];
|
||
foreach (var p in paths)
|
||
{
|
||
fallback.Add(CreateSingleStreamExternalFallback(p));
|
||
}
|
||
|
||
return fallback;
|
||
}
|
||
|
||
private static ExternalAudioFile CreateSingleStreamExternalFallback(string fullPath)
|
||
{
|
||
var ext = Path.GetExtension(fullPath);
|
||
var stream = new ExternalAudioStream
|
||
{
|
||
FileFullPath = fullPath,
|
||
StreamOrdinal = 0,
|
||
CodecName = SidecarDiscoveryService.GuessCodecFromExtension(ext),
|
||
TitleFromProbe = null,
|
||
Channels = null,
|
||
SampleRateHz = null,
|
||
BitRateBps = null
|
||
};
|
||
return new ExternalAudioFile(fullPath, new[] { stream });
|
||
}
|
||
|
||
private static string FormatExternalAudioDetails(ExternalAudioStream st)
|
||
{
|
||
var ch = st.Channels?.ToString(CultureInfo.InvariantCulture) ?? "?";
|
||
var sr = st.SampleRateHz?.ToString(CultureInfo.InvariantCulture) ?? "?";
|
||
var bit = st.BitRateBps is { } br ? $"{((br + 500) / 1000)} kbps" : "?";
|
||
return $"{st.FileFullPath} | stream #{st.StreamOrdinal + 1} | {ch} ch | {sr} Hz | {bit}";
|
||
}
|
||
|
||
public static void SyncTargetFieldsFromProfile(ConversionTaskOverride o, ConversionProfileSettingsEntry profile)
|
||
{
|
||
o.TargetContainer = profile.Container;
|
||
o.TargetVideo = profile.Video;
|
||
o.TargetPixelFormat = profile.PixelFormat;
|
||
o.TargetResolution = profile.Resolution;
|
||
o.TargetFps = profile.Fps;
|
||
o.TargetAudioBitrate = profile.Bitrate;
|
||
o.TargetVideoBitrateMode = profile.VideoBitrateMode;
|
||
o.TargetVideoBitrateMbps = profile.VideoBitrateMbps;
|
||
}
|
||
|
||
private static bool SupportsAttachments(string? container) =>
|
||
!string.IsNullOrWhiteSpace(container) &&
|
||
(container.Contains("mkv", StringComparison.OrdinalIgnoreCase) || container.Contains("matro", StringComparison.OrdinalIgnoreCase));
|
||
|
||
private static bool IsForeignLanguageForAutoRemove(string? language)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(language))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var lang = language.Trim().ToLowerInvariant();
|
||
if (lang is "und" or "unknown" or "?")
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return lang is not ("rus" or "ru");
|
||
}
|
||
|
||
private static bool IsAacCodecName(string? codec) =>
|
||
!string.IsNullOrWhiteSpace(codec)
|
||
&& codec.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static string InferVideoPathFromSidecars(IReadOnlyList<SidecarFile> side)
|
||
{
|
||
if (side.Count == 0)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var dir = Path.GetDirectoryName(side[0].FullPath) ?? string.Empty;
|
||
return Path.Combine(dir, "__unknown__.mkv");
|
||
}
|
||
|
||
private static Dictionary<string, string?> BuildCommonTitleByExternalAudioFile(
|
||
IReadOnlyList<(ExternalAudioFile file, ExternalAudioStream st)> flattened,
|
||
string videoPath,
|
||
SidecarTitleResolver resolver)
|
||
{
|
||
var result = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var grp in flattened.GroupBy(x => x.file.FullPath, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
var anyTag = grp.Any(x => SidecarTitleResolver.NormalizeTitleOrNull(x.st.TitleFromProbe) is not null);
|
||
if (anyTag)
|
||
{
|
||
result[grp.Key] = null;
|
||
continue;
|
||
}
|
||
|
||
result[grp.Key] = resolver.TryRecognizeSidecarTitle(videoPath, grp.Key);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static void LogRecognizedOrFallback(LoggingService? logging, string title)
|
||
{
|
||
if (logging is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (title.StartsWith("RUS", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
logging.Debug($"sidecar title fallback: {title}", "conversion.sidecar");
|
||
}
|
||
else
|
||
{
|
||
logging.Debug($"sidecar title recognized: {title}", "conversion.sidecar");
|
||
}
|
||
}
|
||
|
||
private static void ApplySubtitleDefaultRules(ConversionTaskOverride taskOverride, MediaAnalysisResult media, LoggingService? logging, bool disableSubtitleDefault)
|
||
{
|
||
var subtitleEntries = taskOverride.TrackOverrides
|
||
.Where(t => t.StreamKind == MediaStreamKind.Subtitle)
|
||
.ToList();
|
||
if (disableSubtitleDefault)
|
||
{
|
||
foreach (var entry in subtitleEntries)
|
||
{
|
||
entry.Default = false;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
var forcedCandidates = media.SubtitleStreams
|
||
.Where(s => s.IsForced)
|
||
.OrderBy(s => s.Index)
|
||
.ToList();
|
||
var embeddedSubtitleEntries = subtitleEntries
|
||
.Where(t => t.Source == SourceKind.Embedded && t.Action != TrackActionKind.Remove)
|
||
.ToList();
|
||
|
||
if (forcedCandidates.Count > 0)
|
||
{
|
||
var selectedForcedIndex = forcedCandidates[0].Index;
|
||
if (forcedCandidates.Count > 1)
|
||
{
|
||
logging?.Info("Найдено несколько forced-субтитров, default установлен только для первой дорожки.", "subtitle.forced");
|
||
}
|
||
|
||
foreach (var entry in subtitleEntries)
|
||
{
|
||
entry.Default = false;
|
||
}
|
||
|
||
var selectedEntry = embeddedSubtitleEntries.FirstOrDefault(t => t.StreamIndex == selectedForcedIndex);
|
||
if (selectedEntry is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
selectedEntry.Default = true;
|
||
logging?.Info($"subtitle default set to forced track: #{selectedEntry.StreamIndex}", "subtitle.forced");
|
||
return;
|
||
}
|
||
|
||
var defaultEmbeddedAudio = taskOverride.TrackOverrides
|
||
.FirstOrDefault(t => t.Source == SourceKind.Embedded
|
||
&& t.StreamKind == MediaStreamKind.Audio
|
||
&& t.Action != TrackActionKind.Remove
|
||
&& t.Default == true);
|
||
var defaultAudioIsRus = IsRussianLanguage(defaultEmbeddedAudio?.Language);
|
||
|
||
var rusFullSubtitleEntries = embeddedSubtitleEntries
|
||
.Where(t => IsRussianLanguage(t.Language))
|
||
.Where(t =>
|
||
{
|
||
var stream = media.SubtitleStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
|
||
return stream is null || !stream.IsForced;
|
||
})
|
||
.OrderBy(t => t.StreamIndex)
|
||
.ToList();
|
||
|
||
if (defaultAudioIsRus)
|
||
{
|
||
foreach (var entry in rusFullSubtitleEntries)
|
||
{
|
||
entry.Default = false;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (rusFullSubtitleEntries.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var entry in subtitleEntries)
|
||
{
|
||
entry.Default = false;
|
||
}
|
||
|
||
rusFullSubtitleEntries[0].Default = true;
|
||
logging?.Info($"subtitle default set to first RUS full track: #{rusFullSubtitleEntries[0].StreamIndex}", "subtitle.forced");
|
||
}
|
||
|
||
private static bool IsRussianLanguage(string? language)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(language))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var normalized = language.Trim().ToLowerInvariant();
|
||
return normalized is "ru" or "rus" or "russian" or "рус" or "русский";
|
||
}
|
||
} |