using System.Globalization;
using System.IO;
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// Заполняет значениями по умолчанию.
public static class TrackOverrideSeeder
{
public static void EnsureDefaults(
ConversionTaskOverride o,
MediaAnalysisResult? media,
IReadOnlyList side,
ConversionProfileSettingsEntry profile,
bool autoRemoveForeignTracks = false,
IReadOnlyList? 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(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);
}
/// RUS (один файл) или RUS 1, RUS 2, … (порядок как в ).
public static string BuildExternalSubtitleDisplayTitle(int oneBasedIndex, int totalExternalSubtitles)
{
if (totalExternalSubtitles <= 0 || oneBasedIndex < 1)
{
return "RUS";
}
return totalExternalSubtitles == 1 ? "RUS" : $"RUS {oneBasedIndex}";
}
/// Каноническое title для внешних субтитров: распознанное имя либо fallback RUS / RUS N.
public static string ExternalSubtitleCanonicalTitle(
IReadOnlyList 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 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 ResolveExternalAudioFiles(
IReadOnlyList side,
IReadOnlyList? 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 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 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 BuildCommonTitleByExternalAudioFile(
IReadOnlyList<(ExternalAudioFile file, ExternalAudioStream st)> flattened,
string videoPath,
SidecarTitleResolver resolver)
{
var result = new Dictionary(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 "русский";
}
}