Add MediaStreamProtocol enum (#10153)

* Add MediaStreamProtocol enum

* Add default handling for enum during deserialization

---------

Co-authored-by: Cody Robibero <cody@robibe.ro>
This commit is contained in:
Niels van Velzen 2024-03-05 00:44:54 +01:00 committed by GitHub
parent 83d2bc3f9f
commit 407cf5d0bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 271 additions and 30 deletions

View File

@ -8,6 +8,7 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@ -98,7 +99,7 @@ public class UniversalAudioController : BaseJellyfinApiController
[FromQuery] int? audioBitRate,
[FromQuery] long? startTimeTicks,
[FromQuery] string? transcodingContainer,
[FromQuery] string? transcodingProtocol,
[FromQuery] MediaStreamProtocol? transcodingProtocol,
[FromQuery] int? maxAudioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] bool? enableRemoteMedia,
@ -156,7 +157,7 @@ public class UniversalAudioController : BaseJellyfinApiController
}
var isStatic = mediaSource.SupportsDirectStream;
if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
if (!isStatic && mediaSource.TranscodingSubProtocol == MediaStreamProtocol.Hls)
{
// hls segment container can only be mpegts or fmp4 per ffmpeg documentation
// ffmpeg option -> file extension
@ -232,7 +233,7 @@ public class UniversalAudioController : BaseJellyfinApiController
string[] containers,
string? transcodingContainer,
string? audioCodec,
string? transcodingProtocol,
MediaStreamProtocol? transcodingProtocol,
bool? breakOnNonKeyFrames,
int? transcodingAudioChannels,
int? maxAudioSampleRate,
@ -267,7 +268,7 @@ public class UniversalAudioController : BaseJellyfinApiController
Context = EncodingContext.Streaming,
Container = transcodingContainer ?? "mp3",
AudioCodec = audioCodec ?? "mp3",
Protocol = transcodingProtocol ?? "http",
Protocol = transcodingProtocol ?? MediaStreamProtocol.Http,
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture)
}

View File

@ -0,0 +1,20 @@
using System.ComponentModel;
namespace Jellyfin.Data.Enums;
/// <summary>
/// Media streaming protocol.
/// </summary>
[DefaultValue(Http)]
public enum MediaStreamProtocol
{
/// <summary>
/// HTTP.
/// </summary>
Http = 0,
/// <summary>
/// HTTP Live Streaming.
/// </summary>
Hls = 1
}

View File

@ -557,7 +557,7 @@ namespace MediaBrowser.Model.Dlna
private static void SetStreamInfoOptionsFromDirectPlayProfile(MediaOptions options, MediaSourceInfo item, StreamInfo playlistItem, DirectPlayProfile? directPlayProfile)
{
var container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile);
var protocol = "http";
var protocol = MediaStreamProtocol.Http;
item.TranscodingContainer = container;
item.TranscodingSubProtocol = protocol;
@ -648,7 +648,7 @@ namespace MediaBrowser.Model.Dlna
if (directPlay == PlayMethod.DirectPlay)
{
playlistItem.SubProtocol = "http";
playlistItem.SubProtocol = MediaStreamProtocol.Http;
var audioStreamIndex = directPlayInfo.AudioStreamIndex ?? audioStream?.Index;
if (audioStreamIndex.HasValue)
@ -803,7 +803,7 @@ namespace MediaBrowser.Model.Dlna
var videoCodecs = ContainerProfile.SplitValue(videoCodec);
// Enforce HLS video codec restrictions
if (string.Equals(playlistItem.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
if (playlistItem.SubProtocol == MediaStreamProtocol.Hls)
{
videoCodecs = videoCodecs.Where(codec => _supportedHlsVideoCodecs.Contains(codec)).ToArray();
}
@ -840,7 +840,7 @@ namespace MediaBrowser.Model.Dlna
var audioCodecs = ContainerProfile.SplitValue(audioCodec);
// Enforce HLS audio codec restrictions
if (string.Equals(playlistItem.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
if (playlistItem.SubProtocol == MediaStreamProtocol.Hls)
{
if (string.Equals(playlistItem.Container, "mp4", StringComparison.OrdinalIgnoreCase))
{
@ -1358,9 +1358,9 @@ namespace MediaBrowser.Model.Dlna
PlayMethod playMethod,
ITranscoderSupport transcoderSupport,
string? outputContainer,
string? transcodingSubProtocol)
MediaStreamProtocol? transcodingSubProtocol)
{
if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || !string.Equals(transcodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)))
if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || transcodingSubProtocol != MediaStreamProtocol.Hls))
{
// Look for supported embedded subs of the same format
foreach (var profile in subtitleProfiles)

View File

@ -36,7 +36,7 @@ namespace MediaBrowser.Model.Dlna
public string? Container { get; set; }
public string? SubProtocol { get; set; }
public MediaStreamProtocol SubProtocol { get; set; }
public long StartPositionTicks { get; set; }
@ -670,7 +670,7 @@ namespace MediaBrowser.Model.Dlna
if (MediaType == DlnaProfileType.Audio)
{
if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
if (SubProtocol == MediaStreamProtocol.Hls)
{
return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString);
}
@ -678,7 +678,7 @@ namespace MediaBrowser.Model.Dlna
return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString);
}
if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
if (SubProtocol == MediaStreamProtocol.Hls)
{
return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString);
}
@ -716,9 +716,7 @@ namespace MediaBrowser.Model.Dlna
long startPositionTicks = item.StartPositionTicks;
var isHls = string.Equals(item.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase);
if (isHls)
if (item.SubProtocol == MediaStreamProtocol.Hls)
{
list.Add(new NameValuePair("StartTimeTicks", string.Empty));
}
@ -780,7 +778,7 @@ namespace MediaBrowser.Model.Dlna
list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty));
if (isHls)
if (item.SubProtocol == MediaStreamProtocol.Hls)
{
list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty));
@ -831,7 +829,7 @@ namespace MediaBrowser.Model.Dlna
var list = new List<SubtitleStreamInfo>();
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)
long startPositionTicks = SubProtocol == MediaStreamProtocol.Hls
? 0
: (PlayMethod == PlayMethod.Transcode && !CopyTimestamps ? StartPositionTicks : 0);

View File

@ -3,6 +3,7 @@
using System;
using System.ComponentModel;
using System.Xml.Serialization;
using Jellyfin.Data.Enums;
namespace MediaBrowser.Model.Dlna
{
@ -26,7 +27,7 @@ namespace MediaBrowser.Model.Dlna
public string AudioCodec { get; set; } = string.Empty;
[XmlAttribute("protocol")]
public string Protocol { get; set; } = string.Empty;
public MediaStreamProtocol Protocol { get; set; } = MediaStreamProtocol.Http;
[DefaultValue(false)]
[XmlAttribute("estimateContentLength")]

View File

@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Session;
@ -102,7 +104,7 @@ namespace MediaBrowser.Model.Dto
public string TranscodingUrl { get; set; }
public string TranscodingSubProtocol { get; set; }
public MediaStreamProtocol TranscodingSubProtocol { get; set; }
public string TranscodingContainer { get; set; }

View File

@ -1,6 +1,7 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Jellyfin.Extensions.Json;
namespace MediaBrowser.Providers.Plugins.Omdb
{
@ -12,7 +13,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
/// <inheritdoc />
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
if (reader.IsNull())
{
return null;
}

View File

@ -0,0 +1,49 @@
using System;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Jellyfin.Extensions.Json.Converters;
/// <summary>
/// Json unknown enum converter.
/// </summary>
/// <typeparam name="T">The type of enum.</typeparam>
public class JsonDefaultStringEnumConverter<T> : JsonConverter<T>
where T : struct, Enum
{
private readonly JsonConverter<T> _baseConverter;
/// <summary>
/// Initializes a new instance of the <see cref="JsonDefaultStringEnumConverter{T}"/> class.
/// </summary>
/// <param name="baseConverter">The base json converter.</param>
public JsonDefaultStringEnumConverter(JsonConverter<T> baseConverter)
{
_baseConverter = baseConverter;
}
/// <inheritdoc />
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.IsNull() || reader.IsEmptyString())
{
var customValueAttribute = typeToConvert.GetCustomAttribute<DefaultValueAttribute>();
if (customValueAttribute?.Value is null)
{
throw new InvalidOperationException($"Default value not set for '{typeToConvert.Name}'");
}
return (T)customValueAttribute.Value;
}
return _baseConverter.Read(ref reader, typeToConvert, options);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
_baseConverter.Write(writer, value, options);
}
}

View File

@ -0,0 +1,31 @@
using System;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Jellyfin.Extensions.Json.Converters;
/// <summary>
/// Utilizes the JsonStringEnumConverter and sets a default value if not provided.
/// </summary>
public class JsonDefaultStringEnumConverterFactory : JsonConverterFactory
{
private static readonly JsonStringEnumConverter _baseConverterFactory = new();
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert)
{
return _baseConverterFactory.CanConvert(typeToConvert)
&& typeToConvert.IsDefined(typeof(DefaultValueAttribute));
}
/// <inheritdoc />
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var baseConverter = _baseConverterFactory.CreateConverter(typeToConvert, options);
var converterType = typeof(JsonDefaultStringEnumConverter<>).MakeGenericType(typeToConvert);
return (JsonConverter?)Activator.CreateInstance(converterType, baseConverter);
}
}

View File

@ -12,7 +12,7 @@ namespace Jellyfin.Extensions.Json.Converters
{
/// <inheritdoc />
public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.TokenType == JsonTokenType.Null
=> reader.IsNull()
? Guid.Empty
: ReadInternal(ref reader);

View File

@ -15,10 +15,7 @@ namespace Jellyfin.Extensions.Json.Converters
/// <inheritdoc />
public override TStruct? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Token is empty string.
if (reader.TokenType == JsonTokenType.String
&& ((reader.HasValueSequence && reader.ValueSequence.IsEmpty)
|| (!reader.HasValueSequence && reader.ValueSpan.IsEmpty)))
if (reader.IsEmptyString())
{
return null;
}

View File

@ -38,6 +38,7 @@ namespace Jellyfin.Extensions.Json
new JsonNullableGuidConverter(),
new JsonVersionConverter(),
new JsonFlagEnumConverterFactory(),
new JsonDefaultStringEnumConverterFactory(),
new JsonStringEnumConverter(),
new JsonNullableStructConverterFactory(),
new JsonDateTimeConverter(),

View File

@ -0,0 +1,27 @@
using System.Text.Json;
namespace Jellyfin.Extensions.Json;
/// <summary>
/// Extensions for Utf8JsonReader and Utf8JsonWriter.
/// </summary>
public static class Utf8JsonExtensions
{
/// <summary>
/// Determines if the reader contains an empty string.
/// </summary>
/// <param name="reader">The reader.</param>
/// <returns>Whether the reader contains an empty string.</returns>
public static bool IsEmptyString(this Utf8JsonReader reader)
=> reader.TokenType == JsonTokenType.String
&& ((reader.HasValueSequence && reader.ValueSequence.IsEmpty)
|| (!reader.HasValueSequence && reader.ValueSpan.IsEmpty));
/// <summary>
/// Determines if the reader contains a null value.
/// </summary>
/// <param name="reader">The reader.</param>
/// <returns>Whether the reader contains null.</returns>
public static bool IsNull(this Utf8JsonReader reader)
=> reader.TokenType == JsonTokenType.Null;
}

View File

@ -0,0 +1,112 @@
using System.Text.Json;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json.Converters;
using Xunit;
namespace Jellyfin.Extensions.Tests.Json.Converters;
public class JsonDefaultStringEnumConverterTests
{
private readonly JsonSerializerOptions _jsonOptions = new() { Converters = { new JsonDefaultStringEnumConverterFactory() } };
/// <summary>
/// Test to ensure that `null` and empty string are deserialized to the default value.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="output">The expected enum value.</param>
[Theory]
[InlineData("\"\"", MediaStreamProtocol.Http)]
[InlineData("\"Http\"", MediaStreamProtocol.Http)]
[InlineData("\"Hls\"", MediaStreamProtocol.Hls)]
public void Deserialize_Enum_Direct(string input, MediaStreamProtocol output)
{
var value = JsonSerializer.Deserialize<MediaStreamProtocol>(input, _jsonOptions);
Assert.Equal(output, value);
}
/// <summary>
/// Test to ensure that `null` and empty string are deserialized to the default value.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="output">The expected enum value.</param>
[Theory]
[InlineData(null, MediaStreamProtocol.Http)]
[InlineData("\"\"", MediaStreamProtocol.Http)]
[InlineData("\"Http\"", MediaStreamProtocol.Http)]
[InlineData("\"Hls\"", MediaStreamProtocol.Hls)]
public void Deserialize_Enum(string? input, MediaStreamProtocol output)
{
input ??= "null";
var json = $"{{ \"EnumValue\": {input} }}";
var value = JsonSerializer.Deserialize<TestClass>(json, _jsonOptions);
Assert.NotNull(value);
Assert.Equal(output, value.EnumValue);
}
/// <summary>
/// Test to ensure that empty string is deserialized to the default value,
/// and `null` is deserialized to `null`.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="output">The expected enum value.</param>
[Theory]
[InlineData(null, null)]
[InlineData("\"\"", MediaStreamProtocol.Http)]
[InlineData("\"Http\"", MediaStreamProtocol.Http)]
[InlineData("\"Hls\"", MediaStreamProtocol.Hls)]
public void Deserialize_Enum_Nullable(string? input, MediaStreamProtocol? output)
{
input ??= "null";
var json = $"{{ \"EnumValue\": {input} }}";
var value = JsonSerializer.Deserialize<NullTestClass>(json, _jsonOptions);
Assert.NotNull(value);
Assert.Equal(output, value.EnumValue);
}
/// <summary>
/// Ensures that the roundtrip serialization & deserialization is successful.
/// </summary>
/// <param name="input">Input enum.</param>
/// <param name="output">Output enum.</param>
[Theory]
[InlineData(MediaStreamProtocol.Http, MediaStreamProtocol.Http)]
[InlineData(MediaStreamProtocol.Hls, MediaStreamProtocol.Hls)]
public void Enum_RoundTrip(MediaStreamProtocol input, MediaStreamProtocol output)
{
var inputObj = new TestClass { EnumValue = input };
var outputObj = JsonSerializer.Deserialize<TestClass>(JsonSerializer.Serialize(inputObj, _jsonOptions), _jsonOptions);
Assert.NotNull(outputObj);
Assert.Equal(output, outputObj.EnumValue);
}
/// <summary>
/// Ensures that the roundtrip serialization & deserialization is successful, including null.
/// </summary>
/// <param name="input">Input enum.</param>
/// <param name="output">Output enum.</param>
[Theory]
[InlineData(MediaStreamProtocol.Http, MediaStreamProtocol.Http)]
[InlineData(MediaStreamProtocol.Hls, MediaStreamProtocol.Hls)]
[InlineData(null, null)]
public void Enum_RoundTrip_Nullable(MediaStreamProtocol? input, MediaStreamProtocol? output)
{
var inputObj = new NullTestClass { EnumValue = input };
var outputObj = JsonSerializer.Deserialize<NullTestClass>(JsonSerializer.Serialize(inputObj, _jsonOptions), _jsonOptions);
Assert.NotNull(outputObj);
Assert.Equal(output, outputObj.EnumValue);
}
private sealed class TestClass
{
public MediaStreamProtocol EnumValue { get; set; }
}
private sealed class NullTestClass
{
public MediaStreamProtocol? EnumValue { get; set; }
}
}

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Runtime.Serialization;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
@ -388,21 +389,21 @@ namespace Jellyfin.Model.Tests
// Assert.Equal("webm", val.Container);
Assert.Equal(streamInfo.Container, uri.Extension);
Assert.Equal("stream", uri.Filename);
Assert.Equal("http", streamInfo.SubProtocol);
Assert.Equal(MediaStreamProtocol.Http, streamInfo.SubProtocol);
}
else if (transcodeProtocol.Equals("HLS.mp4", StringComparison.Ordinal))
{
Assert.Equal("mp4", streamInfo.Container);
Assert.Equal("m3u8", uri.Extension);
Assert.Equal("master", uri.Filename);
Assert.Equal("hls", streamInfo.SubProtocol);
Assert.Equal(MediaStreamProtocol.Hls, streamInfo.SubProtocol);
}
else
{
Assert.Equal("ts", streamInfo.Container);
Assert.Equal("m3u8", uri.Extension);
Assert.Equal("master", uri.Filename);
Assert.Equal("hls", streamInfo.SubProtocol);
Assert.Equal(MediaStreamProtocol.Hls, streamInfo.SubProtocol);
}
// Full transcode
@ -488,7 +489,7 @@ namespace Jellyfin.Model.Tests
}
else if (playMethod is null)
{
Assert.Null(streamInfo.SubProtocol);
Assert.Equal(MediaStreamProtocol.Http, streamInfo.SubProtocol);
Assert.Equal("stream", uri.Filename);
Assert.False(streamInfo.EstimateContentLength);