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

393 lines
13 KiB
C#

using System.Globalization;
using System.IO;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed record SidecarAnalysisResult(
string VideoPath,
IReadOnlyList<SidecarFile> Sidecars,
IReadOnlyList<ExternalAudioFile> ExternalAudioFiles);
public sealed class VideoInfoSummaryService
{
private readonly SidecarTitleResolver _titleResolver = new();
public string BuildSummary(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
{
var lines = new List<string>
{
$"Формат: {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<string> BuildAudioLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
{
var lines = new List<string>();
var counters = new Dictionary<string, int>(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<string> BuildSubtitleLines(MediaAnalysisResult media, SidecarAnalysisResult sidecars)
{
var lines = new List<string>();
var counters = new Dictionary<string, int>(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<string, int> 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";
}
}