diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-06-23 17:47:17 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-06-23 17:47:17 +0200 |
| commit | d090c599391928bfabf0e91fb907a3c445b0ff38 (patch) | |
| tree | 67bd7573758b21edaea202da1aa33f23e3521dd9 | |
| parent | 987744529aadd3a5e0da60ae1e6dec0e6e8ea469 (diff) | |
Rework bitrate reporting
5 files changed, 443 insertions, 49 deletions
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 06060988e2..0fe9db8a67 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing { if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue) { - mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Channels); + mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Profile, mediaStream.Channels); } } - var videoStreamsBitrate = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).Select(i => i.BitRate ?? 0).Sum(); - // If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong - if (videoStreamsBitrate == (info.Bitrate ?? 0)) + // ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers). + // Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates. + var videoStreams = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).ToList(); + if (info.Bitrate.HasValue + && videoStreams.Count == 1 + && !videoStreams[0].BitRate.HasValue) { - info.InferTotalBitrate(true); + var otherStreams = info.MediaStreams + .Where(i => i.Type != MediaStreamType.Video && !i.IsExternal) + .ToList(); + + // Only attribute the leftover bitrate to the video stream if every audio stream's bitrate is known. + var audioBitratesKnown = otherStreams + .Where(i => i.Type == MediaStreamType.Audio) + .All(i => i.BitRate.HasValue); + + if (audioBitratesKnown) + { + var estimatedVideoBitrate = info.Bitrate.Value - otherStreams.Sum(i => i.BitRate ?? 0); + if (estimatedVideoBitrate > 0) + { + videoStreams[0].BitRate = estimatedVideoBitrate; + } + } } + + // If the container bitrate is still unknown, infer it from the sum of the streams. + info.InferTotalBitrate(); } return info; @@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s))); } - private static int? GetEstimatedAudioBitrate(string codec, int? channels) + internal static int? GetEstimatedAudioBitrate(string codec, string profile, int? channels) { - if (!channels.HasValue) + if (!channels.HasValue || channels.Value < 1 || string.IsNullOrEmpty(codec)) { return null; } - var channelsValue = channels.Value; - - if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) - { - switch (channelsValue) - { - case <= 2: - return 192000; - case >= 5: - return 320000; - } - } - - if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) - { - switch (channelsValue) - { - case <= 2: - return 192000; - case >= 5: - return 640000; - } - } + // Rough typical bitrates used only as a fallback when ffprobe doesn't report a stream bitrate. + var channelCount = channels.Value; + var isMultichannel = channelCount > 2; - if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) + return codec.ToLowerInvariant() switch { - switch (channelsValue) - { - case <= 2: - return 960000; - case >= 5: - return 2880000; - } - } - - return null; + "aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000, + "ac3" or "eac3" => isMultichannel ? 640000 : 192000, + "dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000), + "opus" => isMultichannel ? 256000 : 128000, + "vorbis" => isMultichannel ? 320000 : 160000, + "wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000, + "flac" or "alac" => channelCount * 480000, + "truehd" or "mlp" => channelCount * 700000, + _ => null + }; } + private static bool IsDtsLossless(string profile) + => profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase); + private void FetchFromItunesInfo(string xml, MediaInfo info) { // Make things simpler and strip out the dtd @@ -972,10 +974,12 @@ namespace MediaBrowser.MediaEncoding.Probing bitrate = value; } - // The bitrate info of FLAC musics and some videos is included in formatInfo. + // The bitrate info of FLAC audio is included in formatInfo. + // Don't do this for video streams: formatInfo.BitRate is the overall container + // bitrate (video + audio + subtitles + overhead), not the video bitrate. if (bitrate == 0 && formatInfo is not null - && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio))) + && isAudio && stream.Type == MediaStreamType.Audio) { // If the stream info doesn't have a bitrate get the value from the media format info if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value)) @@ -1260,9 +1264,16 @@ namespace MediaBrowser.MediaEncoding.Probing } var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); - if (TimeSpan.TryParse(duration, out var parsedDuration)) + if (!string.IsNullOrEmpty(duration)) { - return parsedDuration.TotalSeconds; + // Matroska DURATION tags use nanosecond precision (e.g. "00:00:05.023000000"), but + // TimeSpan only supports up to 7 fractional digits (ticks). Trim the surplus digits so + // these durations parse instead of being silently dropped. + duration = DurationOverPrecisionRegex().Replace(duration, "$1"); + if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsedDuration)) + { + return parsedDuration.TotalSeconds; + } } return null; @@ -1764,5 +1775,8 @@ namespace MediaBrowser.MediaEncoding.Probing [GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")] private static partial Regex PerformerRegex(); + + [GeneratedRegex(@"(\.\d{7})\d+")] + private static partial Regex DurationOverPrecisionRegex(); } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 198cdaa4fc..b723fc7208 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using System.Text.Json; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; @@ -56,6 +57,43 @@ namespace Jellyfin.MediaEncoding.Tests.Probing public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected) => Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar)); + [Theory] + // Lossy codecs, mono/stereo and multichannel. + [InlineData("aac", null, 2, 192000)] + [InlineData("mp3", null, 2, 192000)] + [InlineData("mp2", null, 2, 192000)] + [InlineData("aac", null, 6, 320000)] + [InlineData("ac3", null, 2, 192000)] + [InlineData("eac3", null, 6, 640000)] + [InlineData("opus", null, 2, 128000)] + [InlineData("vorbis", null, 6, 320000)] + [InlineData("wmav2", null, 2, 192000)] + // DTS: the lossy core (any non-MA profile, or none) is flat and caps at 5.1... + [InlineData("dts", null, 2, 768000)] + [InlineData("dts", "DTS", 6, 1509000)] + [InlineData("dts", "DTS-HD HRA", 8, 1509000)] + // ...while lossless DTS-HD MA scales per channel like other lossless codecs. + [InlineData("dts", "DTS-HD MA", 6, 4200000)] + [InlineData("dts", "DTS-HD MA + DTS:X", 8, 5600000)] + // Lossless codecs scale per channel. + [InlineData("flac", null, 2, 960000)] + [InlineData("flac", null, 6, 2880000)] + [InlineData("flac", null, 8, 3840000)] + [InlineData("alac", null, 6, 2880000)] + [InlineData("truehd", null, 2, 1400000)] + [InlineData("truehd", null, 6, 4200000)] + [InlineData("truehd", "Dolby TrueHD + Dolby Atmos", 8, 5600000)] + // 3-4 channel audio must use the multichannel estimate, not return null. + [InlineData("aac", null, 3, 320000)] + [InlineData("ac3", null, 4, 640000)] + // Codec matching is case-insensitive. + [InlineData("AAC", null, 2, 192000)] + // Unknown codec or unknown channel count cannot be estimated. + [InlineData("pcm_s16le", null, 2, null)] + [InlineData("aac", null, null, null)] + public void GetEstimatedAudioBitrate_ReturnsExpected(string codec, string? profile, int? channels, int? expected) + => Assert.Equal(expected, ProbeResultNormalizer.GetEstimatedAudioBitrate(codec, profile, channels)); + [Fact] public void GetMediaInfo_MetaData_Success() { @@ -71,7 +109,10 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("4:3", res.VideoStream.AspectRatio); Assert.Equal(25f, res.VideoStream.AverageFrameRate); Assert.Equal(8, res.VideoStream.BitDepth); - Assert.Equal(69432, res.VideoStream.BitRate); + // ffprobe reports no per-stream video bitrate here. The container bitrate must not be + // misreported as the video bitrate, and the other streams' bitrates exceed the container + // bitrate in this sample, so no sensible video bitrate can be inferred (see #16248). + Assert.Null(res.VideoStream.BitRate); Assert.Equal("h264", res.VideoStream.Codec); Assert.Equal("1/50", res.VideoStream.CodecTimeBase); Assert.Equal(240, res.VideoStream.Height); @@ -322,6 +363,73 @@ namespace Jellyfin.MediaEncoding.Tests.Probing } [Fact] + public void GetMediaInfo_MissingVideoBitrate_EstimatedFromContainer() + { + // ffprobe did not report a per-stream video bitrate. The video bitrate must be estimated + // as the container bitrate minus the other (audio) stream bitrates, not reported as the + // whole container bitrate (see #16248). + var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate.mp4", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + + var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio); + Assert.Equal(128000, audioStream.BitRate); + + // Container bitrate (5128000) minus the audio bitrate (128000). + Assert.Equal(5000000, res.VideoStream.BitRate); + + // The container bitrate itself must remain the overall container bitrate. + Assert.Equal(5128000, res.Bitrate); + } + + [Fact] + public void GetMediaInfo_NanosecondDurationTag_BitrateComputedFromBytes() + { + // The stream carries NUMBER_OF_BYTES and a nanosecond-precision DURATION tag but no + // bitrate. TimeSpan only supports 7 fractional digits, so the 9-digit DURATION must be + // trimmed for the duration to parse and the bitrate to be computed (bytes * 8 / seconds). + var bytes = File.ReadAllBytes("Test Data/Probing/video_nanosecond_duration_bitrate.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_nanosecond_duration_bitrate.mkv", MediaProtocol.File); + + Assert.NotNull(res.VideoStream); + + // 10000000 bytes * 8 / 100 seconds. + Assert.Equal(800000, res.VideoStream.BitRate); + } + + [Fact] + public void GetMediaInfo_MissingVideoBitrate_UnknownAudioBitrate_NotEstimated() + { + // ffprobe reported no per-stream video bitrate and the audio bitrate cannot be estimated + // (the audio stream has no channel count, so GetEstimatedAudioBitrate returns null). The + // video bitrate must be left unset rather than wrongly absorbing the unaccounted audio + // bitrate (see #16248). + var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate_unknown_audio.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate_unknown_audio.mp4", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Null(res.VideoStream.BitRate); + + var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio); + Assert.Null(audioStream.BitRate); + + // The overall container bitrate is still reported. + Assert.Equal(5128000, res.Bitrate); + } + + [Fact] public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success() { var bytes = File.ReadAllBytes("Test Data/Probing/video_single_frame_mjpeg.json"); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json new file mode 100644 index 0000000000..803a3a7e5f --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json @@ -0,0 +1,113 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1/48", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "closed_captions": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/12288", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3686400, + "duration": "300.000000", + "bits_per_raw_sample": "8", + "nb_frames": "7200", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "mp4a", + "codec_tag": "0x6134706d", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 14400000, + "duration": "300.000000", + "bit_rate": "128000", + "nb_frames": "14063", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng", + "handler_name": "SoundHandler" + } + } + ], + "format": { + "filename": "test.1080p.mp4", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "192000000", + "bit_rate": "5128000", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json new file mode 100644 index 0000000000..ff6dc51f27 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json @@ -0,0 +1,110 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1/48", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "closed_captions": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/12288", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3686400, + "duration": "300.000000", + "bits_per_raw_sample": "8", + "nb_frames": "7200", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler" + } + }, + { + "index": 1, + "codec_name": "dts", + "codec_long_name": "DCA (DTS Coherent Acoustics)", + "profile": "DTS-HD MA", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "s32p", + "sample_rate": "48000", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 14400000, + "duration": "300.000000", + "nb_frames": "14063", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng", + "handler_name": "SoundHandler" + } + } + ], + "format": { + "filename": "test.1080p.mp4", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "192000000", + "bit_rate": "5128000", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json new file mode 100644 index 0000000000..ff8b2ca80a --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json @@ -0,0 +1,49 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "disposition": { + "default": 1 + }, + "tags": { + "language": "eng", + "BPS-eng": "", + "DURATION-eng": "00:01:40.000000000", + "NUMBER_OF_FRAMES-eng": "2400", + "NUMBER_OF_BYTES-eng": "10000000" + } + } + ], + "format": { + "filename": "video_nanosecond_duration_bitrate.mkv", + "nb_streams": 1, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "100.000000", + "size": "10001000", + "probe_score": 100, + "tags": { + "encoder": "libebml v1.4.2 + libmatroska v1.6.4" + } + } +} |
