mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-29 19:11:00 +01:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3ee1e84b1 | ||
|
|
1035f6a101 | ||
|
|
70b4589382 | ||
|
|
d090c59939 |
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
{
|
{
|
||||||
BaseItemKind.Person, [
|
BaseItemKind.Person, [
|
||||||
BaseItemKind.Audio,
|
BaseItemKind.Audio,
|
||||||
|
BaseItemKind.AudioBook,
|
||||||
|
BaseItemKind.Book,
|
||||||
BaseItemKind.Episode,
|
BaseItemKind.Episode,
|
||||||
BaseItemKind.Movie,
|
BaseItemKind.Movie,
|
||||||
BaseItemKind.LiveTvProgram,
|
BaseItemKind.LiveTvProgram,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using Jellyfin.Api.Extensions;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
|
|||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Images
|
namespace Emby.Server.Implementations.Images
|
||||||
{
|
{
|
||||||
@@ -28,38 +28,7 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
var view = (CollectionFolder)item;
|
var view = (CollectionFolder)item;
|
||||||
var viewType = view.CollectionType;
|
var viewType = view.CollectionType;
|
||||||
|
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
|
||||||
BaseItemKind[] includeItemTypes;
|
|
||||||
|
|
||||||
switch (viewType)
|
|
||||||
{
|
|
||||||
case CollectionType.movies:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Movie };
|
|
||||||
break;
|
|
||||||
case CollectionType.tvshows:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Series };
|
|
||||||
break;
|
|
||||||
case CollectionType.music:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
|
|
||||||
break;
|
|
||||||
case CollectionType.musicvideos:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.MusicVideo };
|
|
||||||
break;
|
|
||||||
case CollectionType.books:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
|
|
||||||
break;
|
|
||||||
case CollectionType.boxsets:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.BoxSet };
|
|
||||||
break;
|
|
||||||
case CollectionType.homevideos:
|
|
||||||
case CollectionType.photos:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var recursive = viewType != CollectionType.playlists;
|
var recursive = viewType != CollectionType.playlists;
|
||||||
|
|
||||||
return view.GetItemList(new InternalItemsQuery
|
return view.GetItemList(new InternalItemsQuery
|
||||||
@@ -67,12 +36,9 @@ namespace Emby.Server.Implementations.Images
|
|||||||
CollapseBoxSetItems = false,
|
CollapseBoxSetItems = false,
|
||||||
Recursive = recursive,
|
Recursive = recursive,
|
||||||
DtoOptions = new DtoOptions(false),
|
DtoOptions = new DtoOptions(false),
|
||||||
ImageTypes = new[] { ImageType.Primary },
|
ImageTypes = [ImageType.Primary],
|
||||||
Limit = 8,
|
Limit = 8,
|
||||||
OrderBy = new[]
|
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
|
||||||
{
|
|
||||||
(ItemSortBy.Random, SortOrder.Ascending)
|
|
||||||
},
|
|
||||||
IncludeItemTypes = includeItemTypes
|
IncludeItemTypes = includeItemTypes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
QueryResult<BaseItem> result;
|
QueryResult<BaseItem> result;
|
||||||
|
|
||||||
Guid[] linkedChildAncestorIds = [];
|
Guid[] linkedChildAncestorIds = [];
|
||||||
|
|
||||||
|
includeItemTypes ??= [];
|
||||||
if (includeItemTypes.Length == 1
|
if (includeItemTypes.Length == 1
|
||||||
&& (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist)
|
&& (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist)
|
||||||
&& item is not BoxSet
|
&& item is not BoxSet
|
||||||
@@ -314,6 +316,7 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
if (folder is IHasCollectionType hasCollectionType)
|
if (folder is IHasCollectionType hasCollectionType)
|
||||||
{
|
{
|
||||||
collectionType = hasCollectionType.CollectionType;
|
collectionType = hasCollectionType.CollectionType;
|
||||||
|
includeItemTypes = [.. includeItemTypes.Union(DtoExtensions.GetBaseItemKindsForCollectionType(collectionType))];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collectionType == CollectionType.playlists)
|
if (collectionType == CollectionType.playlists)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
using MediaBrowser.Controller.Dto;
|
using MediaBrowser.Controller.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
@@ -9,6 +10,35 @@ namespace Jellyfin.Api.Extensions;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DtoExtensions
|
public static class DtoExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the BaseItemKind values associated with the specified CollectionType.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="collectionType">The collection type to map to BaseItemKind values.</param>
|
||||||
|
/// <returns>An array of BaseItemKind values that correspond to the collection type.</returns>
|
||||||
|
public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType)
|
||||||
|
{
|
||||||
|
switch (collectionType)
|
||||||
|
{
|
||||||
|
case CollectionType.movies:
|
||||||
|
return [BaseItemKind.Movie];
|
||||||
|
case CollectionType.tvshows:
|
||||||
|
return [BaseItemKind.Series];
|
||||||
|
case CollectionType.music:
|
||||||
|
return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist];
|
||||||
|
case CollectionType.musicvideos:
|
||||||
|
return [BaseItemKind.MusicVideo];
|
||||||
|
case CollectionType.books:
|
||||||
|
return [BaseItemKind.Book, BaseItemKind.AudioBook];
|
||||||
|
case CollectionType.boxsets:
|
||||||
|
return [BaseItemKind.BoxSet];
|
||||||
|
case CollectionType.homevideos:
|
||||||
|
case CollectionType.photos:
|
||||||
|
return [BaseItemKind.Video, BaseItemKind.Photo];
|
||||||
|
default:
|
||||||
|
return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Add additional DtoOptions.
|
/// Add additional DtoOptions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
case CollectionType.folders:
|
case CollectionType.folders:
|
||||||
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
|
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
|
||||||
|
|
||||||
|
case CollectionType.books:
|
||||||
|
return GetBooks(queryParent, user, query);
|
||||||
|
|
||||||
case CollectionType.tvshows:
|
case CollectionType.tvshows:
|
||||||
return GetTvView(queryParent, user, query);
|
return GetTvView(queryParent, user, query);
|
||||||
|
|
||||||
@@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
return _libraryManager.GetItemsResult(query);
|
return _libraryManager.GetItemsResult(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private QueryResult<BaseItem> GetBooks(Folder parent, User user, InternalItemsQuery query)
|
||||||
|
{
|
||||||
|
query.Recursive = true;
|
||||||
|
query.Parent = parent;
|
||||||
|
query.SetUser(user);
|
||||||
|
|
||||||
|
query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
|
||||||
|
|
||||||
|
return _libraryManager.GetItemsResult(query);
|
||||||
|
}
|
||||||
|
|
||||||
private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
|
private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
|
||||||
{
|
{
|
||||||
query.Recursive = true;
|
query.Recursive = true;
|
||||||
|
|||||||
@@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||||||
{
|
{
|
||||||
if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue)
|
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();
|
// ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers).
|
||||||
// If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong
|
// Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates.
|
||||||
if (videoStreamsBitrate == (info.Bitrate ?? 0))
|
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;
|
return info;
|
||||||
@@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||||||
return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s)));
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelsValue = channels.Value;
|
// 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, "aac", StringComparison.OrdinalIgnoreCase)
|
return codec.ToLowerInvariant() switch
|
||||||
|| string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
switch (channelsValue)
|
"aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000,
|
||||||
{
|
"ac3" or "eac3" => isMultichannel ? 640000 : 192000,
|
||||||
case <= 2:
|
"dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000),
|
||||||
return 192000;
|
"opus" => isMultichannel ? 256000 : 128000,
|
||||||
case >= 5:
|
"vorbis" => isMultichannel ? 320000 : 160000,
|
||||||
return 320000;
|
"wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000,
|
||||||
}
|
"flac" or "alac" => channelCount * 480000,
|
||||||
}
|
"truehd" or "mlp" => channelCount * 700000,
|
||||||
|
_ => null
|
||||||
if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)
|
};
|
||||||
|| string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
switch (channelsValue)
|
|
||||||
{
|
|
||||||
case <= 2:
|
|
||||||
return 192000;
|
|
||||||
case >= 5:
|
|
||||||
return 640000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
switch (channelsValue)
|
|
||||||
{
|
|
||||||
case <= 2:
|
|
||||||
return 960000;
|
|
||||||
case >= 5:
|
|
||||||
return 2880000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsDtsLossless(string profile)
|
||||||
|
=> profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private void FetchFromItunesInfo(string xml, MediaInfo info)
|
private void FetchFromItunesInfo(string xml, MediaInfo info)
|
||||||
{
|
{
|
||||||
// Make things simpler and strip out the dtd
|
// Make things simpler and strip out the dtd
|
||||||
@@ -972,10 +974,12 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||||||
bitrate = value;
|
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
|
if (bitrate == 0
|
||||||
&& formatInfo is not null
|
&& 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 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))
|
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");
|
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;
|
return null;
|
||||||
@@ -1764,5 +1775,8 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||||||
|
|
||||||
[GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")]
|
[GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")]
|
||||||
private static partial Regex PerformerRegex();
|
private static partial Regex PerformerRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(\.\d{7})\d+")]
|
||||||
|
private static partial Regex DurationOverPrecisionRegex();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
@@ -56,6 +57,43 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
|
|||||||
public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected)
|
public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected)
|
||||||
=> Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar));
|
=> 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]
|
[Fact]
|
||||||
public void GetMediaInfo_MetaData_Success()
|
public void GetMediaInfo_MetaData_Success()
|
||||||
{
|
{
|
||||||
@@ -71,7 +109,10 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
|
|||||||
Assert.Equal("4:3", res.VideoStream.AspectRatio);
|
Assert.Equal("4:3", res.VideoStream.AspectRatio);
|
||||||
Assert.Equal(25f, res.VideoStream.AverageFrameRate);
|
Assert.Equal(25f, res.VideoStream.AverageFrameRate);
|
||||||
Assert.Equal(8, res.VideoStream.BitDepth);
|
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("h264", res.VideoStream.Codec);
|
||||||
Assert.Equal("1/50", res.VideoStream.CodecTimeBase);
|
Assert.Equal("1/50", res.VideoStream.CodecTimeBase);
|
||||||
Assert.Equal(240, res.VideoStream.Height);
|
Assert.Equal(240, res.VideoStream.Height);
|
||||||
@@ -321,6 +362,73 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
|
|||||||
Assert.True(res.VideoStream.IsDefault);
|
Assert.True(res.VideoStream.IsDefault);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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]
|
[Fact]
|
||||||
public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success()
|
public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user