393 lines
13 KiB
C#
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";
|
|
}
|
|
}
|