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 "русский"; } }