Переглянути джерело

Add MediaStreamProtocol enum (#10153)

* Add MediaStreamProtocol enum

* Add default handling for enum during deserialization

---------

Co-authored-by: Cody Robibero <cody@robibe.ro>
Niels van Velzen 1 рік тому
батько
коміт
407cf5d0bf

+ 5 - 4
Jellyfin.Api/Controllers/UniversalAudioController.cs

@@ -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)
             }

+ 20 - 0
Jellyfin.Data/Enums/MediaStreamProtocol.cs

@@ -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
+}

+ 6 - 6
MediaBrowser.Model/Dlna/StreamBuilder.cs

@@ -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)

+ 6 - 8
MediaBrowser.Model/Dlna/StreamInfo.cs

@@ -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);
 

+ 2 - 1
MediaBrowser.Model/Dlna/TranscodingProfile.cs

@@ -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")]

+ 3 - 1
MediaBrowser.Model/Dto/MediaSourceInfo.cs

@@ -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; }
 

+ 2 - 1
MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs

@@ -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;
             }

+ 49 - 0
src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs

@@ -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);
+    }
+}

+ 31 - 0
src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs

@@ -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);
+    }
+}

+ 1 - 1
src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs

@@ -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);
 

+ 1 - 4
src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs

@@ -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;
             }

+ 1 - 0
src/Jellyfin.Extensions/Json/JsonDefaults.cs

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

+ 27 - 0
src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs

@@ -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;
+}

+ 112 - 0
tests/Jellyfin.Extensions.Tests/Json/Converters/JsonDefaultStringEnumConverterTests.cs

@@ -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; }
+    }
+}

+ 5 - 4
tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs

@@ -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);