using System.Globalization; using System.IO; using System.Text; using EmbyToolbox.Models; namespace EmbyToolbox.Services; public sealed record SidecarAnalysisResult( string VideoPath, IReadOnlyList Sidecars, IReadOnlyList ExternalAudioFiles); public sealed class VideoInfoSummaryService { private readonly SidecarTitleResolver _titleResolver = new(); public string BuildSummary(MediaAnalysisResult media, SidecarAnalysisResult sidecars) { var lines = new List { $"Формат: {NormalizeContainer(media.ContainerFormat, sidecars.VideoPath)}", "Видео:", $"\t- {FormatVideoLine(media)}", "Аудио:" }; var audioLines = BuildAudioLines(media, sidecars); if (audioLines.Count == 0) { lines.Add("\t- ?"); } else { foreach (var audioLine in audioLines) { lines.Add("\t- " + audioLine); } } lines.Add("Субтитры:"); var subtitleLines = BuildSubtitleLines(media, sidecars); if (subtitleLines.Count == 0) { lines.Add("\t- ?"); } else { foreach (var subtitleLine in subtitleLines) { lines.Add("\t- " + subtitleLine); } } var attachmentsLine = FormatAttachmentsLine(media); if (!string.IsNullOrWhiteSpace(attachmentsLine)) { lines.Add(attachmentsLine); } return string.Join(Environment.NewLine, lines); } public string NormalizeContainer(string? formatName, string videoPath) { var format = (formatName ?? string.Empty).ToLowerInvariant(); var ext = Path.GetExtension(videoPath).ToLowerInvariant(); if (format.Contains("matroska", StringComparison.Ordinal) || format.Contains("webm", StringComparison.Ordinal)) { return ext switch { ".webm" => "WebM", _ => "MKV" }; } if (format.Contains("mov", StringComparison.Ordinal) || format.Contains("mp4", StringComparison.Ordinal) || format.Contains("m4a", StringComparison.Ordinal) || format.Contains("3gp", StringComparison.Ordinal) || format.Contains("3g2", StringComparison.Ordinal) || format.Contains("mj2", StringComparison.Ordinal)) { return "MP4"; } if (format.Contains("avi", StringComparison.Ordinal)) { return "AVI"; } if (format.Contains("mpegts", StringComparison.Ordinal)) { return "TS"; } return format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault()?.ToUpperInvariant() ?? "?"; } public string FormatVideoLine(MediaAnalysisResult media) { var v = media.PrimaryVideo; if (v is null) { return "?"; } var codec = NormalizeVideoCodec(v); var profile = NormalizeVideoProfile(v); var resolution = v.Width is { } w && v.Height is { } h ? $"{w}x{h}" : "?"; var bitrate = FormatBitrate(v.BitRateBps ?? media.FormatBitRateBps); var fps = FormatFps(v.AverageFrameRate ?? v.FrameRate); return $"{codec}{profile}, {resolution}, {bitrate}, {fps}"; } public string FormatAudioLine(string lang, bool isExternal, int index, string codec, int? sampleRateHz, int? channels, long? bitrateBps, string? title) { var ext = isExternal ? "(ext)" : string.Empty; var idx = index > 0 ? $" {index}" : string.Empty; var titlePart = string.IsNullOrWhiteSpace(title) ? string.Empty : $" [{title.Trim()}]"; var channelPart = channels is { } ch ? $"{ch}ch" : "?ch"; return $"{lang}{ext}{idx}: {codec}, {FormatSampleRate(sampleRateHz)}, {channelPart}, {FormatBitrate(bitrateBps)}{titlePart}"; } public string FormatSubtitleLine(string lang, bool isExternal, int index, string codec, string? title) { var ext = isExternal ? "(ext)" : string.Empty; var idx = index > 0 ? $" {index}" : string.Empty; var titlePart = string.IsNullOrWhiteSpace(title) ? string.Empty : $" [{title.Trim()}]"; return $"{lang}{ext}{idx}: {codec}{titlePart}"; } public string? FormatAttachmentsLine(MediaAnalysisResult media) { var attachments = media.AllStreams.Where(s => s.Kind == MediaStreamKind.Attachment).ToList(); if (attachments.Count == 0) { return null; } var fontCount = attachments.Count(IsFontAttachment); return fontCount > 0 ? $"Attachments: {attachments.Count} files, fonts: {fontCount}" : $"Attachments: {attachments.Count} files"; } private List BuildAudioLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars) { var lines = new List(); var counters = new Dictionary(StringComparer.Ordinal); foreach (var a in media.AudioStreams.OrderBy(x => x.Index)) { var lang = NormalizeLanguage(a.Language, external: false); var key = $"in:{lang}"; var nextIndex = AddAndGetIndex(counters, key); var displayIndex = nextIndex > 1 ? nextIndex : 0; lines.Add(FormatAudioLine( lang, isExternal: false, index: displayIndex, NormalizeAudioCodec(a.CodecName), a.SampleRateHz, a.Channels, a.BitRateBps, a.Title)); } var externalStreams = sidecars.ExternalAudioFiles .SelectMany(f => f.Streams.Select(s => (file: f, stream: s))) .ToList(); var rusFallbackIndex = 0; foreach (var e in externalStreams) { var lang = "RUS"; var key = $"ext:{lang}"; var nextIndex = AddAndGetIndex(counters, key); var displayIndex = nextIndex > 1 ? nextIndex : 1; string? title = SidecarTitleResolver.NormalizeTitleOrNull(e.stream.TitleFromProbe); if (title is null) { title = _titleResolver.TryRecognizeSidecarTitle(sidecars.VideoPath, e.file.FullPath); } if (title is null) { rusFallbackIndex++; title = SidecarTitleResolver.BuildRusAudioFallback(rusFallbackIndex); } lines.Add(FormatAudioLine( lang, isExternal: true, index: displayIndex, NormalizeAudioCodec(e.stream.CodecName), e.stream.SampleRateHz, e.stream.Channels, e.stream.BitRateBps, title)); } return lines; } private List BuildSubtitleLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars) { var lines = new List(); var counters = new Dictionary(StringComparer.Ordinal); foreach (var s in media.SubtitleStreams.OrderBy(x => x.Index)) { var lang = NormalizeLanguage(s.Language, external: false); var key = $"in:{lang}"; var nextIndex = AddAndGetIndex(counters, key); var displayIndex = nextIndex > 1 ? nextIndex : 0; lines.Add(FormatSubtitleLine(lang, false, displayIndex, NormalizeSubtitleCodec(s.CodecName), s.Title)); } var externalSubs = sidecars.Sidecars.Where(s => s.IsSubtitle).OrderBy(s => s.FileName, StringComparer.OrdinalIgnoreCase).ToList(); var fallbackRusIdx = 0; foreach (var sub in externalSubs) { var lang = "RUS"; var key = $"ext:{lang}"; var nextIndex = AddAndGetIndex(counters, key); var displayIndex = nextIndex > 1 ? nextIndex : 0; var title = _titleResolver.TryRecognizeSidecarTitle(sidecars.VideoPath, sub.FullPath); if (string.IsNullOrWhiteSpace(title)) { fallbackRusIdx++; title = SidecarTitleResolver.BuildRusSubtitleFallback(fallbackRusIdx); } lines.Add(FormatSubtitleLine(lang, true, displayIndex, NormalizeSubtitleCodecByExtension(sub.FullPath), title)); } return lines; } private static int AddAndGetIndex(Dictionary counters, string key) { counters.TryGetValue(key, out var current); current++; counters[key] = current; return current; } private static string NormalizeLanguage(string? language, bool external) { if (external && string.IsNullOrWhiteSpace(language)) { return "RUS"; } var l = (language ?? string.Empty).Trim().ToLowerInvariant(); return l switch { "" or "unknown" or "und" or "?" => "UND", "rus" or "ru" => "RUS", "jpn" or "ja" => "JPN", "eng" or "en" => "ENG", _ => l.ToUpperInvariant() }; } private static string NormalizeVideoCodec(MediaStreamInfo v) { var codec = (v.CodecName ?? string.Empty).Trim().ToLowerInvariant(); if (codec.Contains("h264", StringComparison.Ordinal) || codec.Contains("avc", StringComparison.Ordinal)) { return (v.Encoder ?? string.Empty).Contains("x264", StringComparison.OrdinalIgnoreCase) ? "x264" : "H.264"; } if (codec.Contains("hevc", StringComparison.Ordinal) || codec.Contains("h265", StringComparison.Ordinal)) { return "H.265"; } return string.IsNullOrWhiteSpace(v.CodecName) ? "?" : v.CodecName.ToUpperInvariant(); } private static string NormalizeVideoProfile(MediaStreamInfo v) { var profile = (v.Profile ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(profile)) { if ((v.PixelFormat ?? string.Empty).Contains("10", StringComparison.OrdinalIgnoreCase)) { return " (10-bit)"; } return string.Empty; } if (profile.Contains("high 10", StringComparison.OrdinalIgnoreCase)) { return " (Hi10p)"; } return $" ({profile})"; } private static string NormalizeAudioCodec(string? codec) { var c = (codec ?? string.Empty).Trim().ToLowerInvariant(); return c switch { "aac" => "AAC", "ac3" => "AC3", "eac3" => "EAC3", "dts" => "DTS", "flac" => "FLAC", "mp3" => "MP3", "mp2" => "MP2", "opus" => "OPUS", _ => string.IsNullOrWhiteSpace(codec) ? "?" : codec.ToUpperInvariant() }; } private static string NormalizeSubtitleCodec(string? codec) { var c = (codec ?? string.Empty).Trim().ToLowerInvariant(); return c switch { "subrip" => "SRT", "ass" => "ASS", "ssa" => "SSA", "hdmv_pgs_subtitle" => "PGS", "dvd_subtitle" => "VobSub", "mov_text" => "MOV_TEXT", _ => string.IsNullOrWhiteSpace(codec) ? "?" : codec.ToUpperInvariant() }; } private static string NormalizeSubtitleCodecByExtension(string path) { var ext = Path.GetExtension(path).ToLowerInvariant(); return ext switch { ".srt" => "SRT", ".ass" => "ASS", ".ssa" => "SSA", ".sup" => "PGS", ".sub" or ".idx" => "VobSub", ".vtt" => "WEBVTT", _ => ext.TrimStart('.').ToUpperInvariant() }; } private static string FormatBitrate(long? bps) { if (bps is not { } value || value <= 0) { return "?"; } var kbps = value / 1000d; if (kbps >= 10000) { return $"{FormatDecimal(kbps / 1000d)} Mbps"; } return $"{FormatDecimal(kbps)} kbps"; } private static string FormatSampleRate(int? sampleRateHz) => sampleRateHz is { } hz && hz > 0 ? $"{hz} Hz" : "? Hz"; private static string FormatFps(double? fps) => fps is { } value && value > 0 ? $"{FormatDecimal(value)} fps" : "? fps"; private static string FormatDecimal(double value) => value.ToString("0.###", CultureInfo.GetCultureInfo("ru-RU")); private static bool IsFontAttachment(MediaStreamInfo stream) { if (stream.Kind != MediaStreamKind.Attachment) { return false; } var mime = (stream.AttachmentDeclaredMimeType ?? string.Empty).ToLowerInvariant(); if (mime.Contains("font", StringComparison.Ordinal) || mime.Contains("truetype", StringComparison.Ordinal) || mime.Contains("opentype", StringComparison.Ordinal) || mime.Contains("sfnt", StringComparison.Ordinal)) { return true; } var name = stream.AttachmentDeclaredFileName ?? string.Empty; var ext = Path.GetExtension(name).ToLowerInvariant(); return ext is ".ttf" or ".otf" or ".ttc" or ".otc"; } }