emby-toolbox/EmbyToolbox/Services/TrackOverrideSeeder.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

505 lines
19 KiB
C#
Raw 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.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 "русский";
}
}