aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-06-23 17:47:17 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-06-23 17:47:17 +0200
commitd090c599391928bfabf0e91fb907a3c445b0ff38 (patch)
tree67bd7573758b21edaea202da1aa33f23e3521dd9 /tests
parent987744529aadd3a5e0da60ae1e6dec0e6e8ea469 (diff)
Rework bitrate reporting
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs110
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json113
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json110
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json49
4 files changed, 381 insertions, 1 deletions
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"
+ }
+ }
+}