فهرست منبع

Merge remote-tracking branch 'remotes/upstream/api-migration' into api-channel

# Conflicts:
#	Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
crobibero 5 سال پیش
والد
کامیت
88b6c26472

+ 125 - 0
Jellyfin.Api/Controllers/ConfigurationController.cs

@@ -0,0 +1,125 @@
+#nullable enable
+
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.ConfigurationDtos;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Configuration;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Configuration Controller.
+    /// </summary>
+    [Route("System")]
+    [Authorize]
+    public class ConfigurationController : BaseJellyfinApiController
+    {
+        private readonly IServerConfigurationManager _configurationManager;
+        private readonly IMediaEncoder _mediaEncoder;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ConfigurationController"/> class.
+        /// </summary>
+        /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        public ConfigurationController(
+            IServerConfigurationManager configurationManager,
+            IMediaEncoder mediaEncoder)
+        {
+            _configurationManager = configurationManager;
+            _mediaEncoder = mediaEncoder;
+        }
+
+        /// <summary>
+        /// Gets application configuration.
+        /// </summary>
+        /// <response code="200">Application configuration returned.</response>
+        /// <returns>Application configuration.</returns>
+        [HttpGet("Configuration")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<ServerConfiguration> GetConfiguration()
+        {
+            return _configurationManager.Configuration;
+        }
+
+        /// <summary>
+        /// Updates application configuration.
+        /// </summary>
+        /// <param name="configuration">Configuration.</param>
+        /// <response code="200">Configuration updated.</response>
+        /// <returns>Update status.</returns>
+        [HttpPost("Configuration")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult UpdateConfiguration([FromBody, BindRequired] ServerConfiguration configuration)
+        {
+            _configurationManager.ReplaceConfiguration(configuration);
+            return Ok();
+        }
+
+        /// <summary>
+        /// Gets a named configuration.
+        /// </summary>
+        /// <param name="key">Configuration key.</param>
+        /// <response code="200">Configuration returned.</response>
+        /// <returns>Configuration.</returns>
+        [HttpGet("Configuration/{Key}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<object> GetNamedConfiguration([FromRoute] string key)
+        {
+            return _configurationManager.GetConfiguration(key);
+        }
+
+        /// <summary>
+        /// Updates named configuration.
+        /// </summary>
+        /// <param name="key">Configuration key.</param>
+        /// <response code="200">Named configuration updated.</response>
+        /// <returns>Update status.</returns>
+        [HttpPost("Configuration/{Key}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key)
+        {
+            var configurationType = _configurationManager.GetConfigurationType(key);
+            var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType);
+            _configurationManager.SaveConfiguration(key, configuration);
+            return Ok();
+        }
+
+        /// <summary>
+        /// Gets a default MetadataOptions object.
+        /// </summary>
+        /// <response code="200">Metadata options returned.</response>
+        /// <returns>Default MetadataOptions.</returns>
+        [HttpGet("Configuration/MetadataOptions/Default")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<MetadataOptions> GetDefaultMetadataOptions()
+        {
+            return new MetadataOptions();
+        }
+
+        /// <summary>
+        /// Updates the path to the media encoder.
+        /// </summary>
+        /// <param name="mediaEncoderPath">Media encoder path form body.</param>
+        /// <response code="200">Media encoder path updated.</response>
+        /// <returns>Status.</returns>
+        [HttpPost("MediaEncoder/Path")]
+        [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult UpdateMediaEncoderPath([FromForm, BindRequired] MediaEncoderPathDto mediaEncoderPath)
+        {
+            _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
+            return Ok();
+        }
+    }
+}

+ 39 - 19
Jellyfin.Api/Controllers/StartupController.cs

@@ -5,6 +5,7 @@ using Jellyfin.Api.Models.StartupDtos;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
 
 namespace Jellyfin.Api.Controllers
@@ -30,22 +31,28 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Api endpoint for completing the startup wizard.
+        /// Completes the startup wizard.
         /// </summary>
+        /// <response code="200">Startup wizard completed.</response>
+        /// <returns>An <see cref="OkResult"/> indicating success.</returns>
         [HttpPost("Complete")]
-        public void CompleteWizard()
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult CompleteWizard()
         {
             _config.Configuration.IsStartupWizardCompleted = true;
             _config.SetOptimalValues();
             _config.SaveConfiguration();
+            return Ok();
         }
 
         /// <summary>
-        /// Endpoint for getting the initial startup wizard configuration.
+        /// Gets the initial startup wizard configuration.
         /// </summary>
-        /// <returns>The initial startup wizard configuration.</returns>
+        /// <response code="200">Initial startup wizard configuration retrieved.</response>
+        /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
         [HttpGet("Configuration")]
-        public StartupConfigurationDto GetStartupConfiguration()
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
         {
             var result = new StartupConfigurationDto
             {
@@ -58,13 +65,16 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Endpoint for updating the initial startup wizard configuration.
+        /// Sets the initial startup wizard configuration.
         /// </summary>
         /// <param name="uiCulture">The UI language culture.</param>
         /// <param name="metadataCountryCode">The metadata country code.</param>
         /// <param name="preferredMetadataLanguage">The preferred language for metadata.</param>
+        /// <response code="200">Configuration saved.</response>
+        /// <returns>An <see cref="OkResult"/> indicating success.</returns>
         [HttpPost("Configuration")]
-        public void UpdateInitialConfiguration(
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult UpdateInitialConfiguration(
             [FromForm] string uiCulture,
             [FromForm] string metadataCountryCode,
             [FromForm] string preferredMetadataLanguage)
@@ -73,43 +83,51 @@ namespace Jellyfin.Api.Controllers
             _config.Configuration.MetadataCountryCode = metadataCountryCode;
             _config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage;
             _config.SaveConfiguration();
+            return Ok();
         }
 
         /// <summary>
-        /// Endpoint for (dis)allowing remote access and UPnP.
+        /// Sets remote access and UPnP.
         /// </summary>
         /// <param name="enableRemoteAccess">Enable remote access.</param>
         /// <param name="enableAutomaticPortMapping">Enable UPnP.</param>
+        /// <response code="200">Configuration saved.</response>
+        /// <returns>An <see cref="OkResult"/> indicating success.</returns>
         [HttpPost("RemoteAccess")]
-        public void SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
         {
             _config.Configuration.EnableRemoteAccess = enableRemoteAccess;
             _config.Configuration.EnableUPnP = enableAutomaticPortMapping;
             _config.SaveConfiguration();
+            return Ok();
         }
 
         /// <summary>
-        /// Endpoint for returning the first user.
+        /// Gets the first user.
         /// </summary>
+        /// <response code="200">Initial user retrieved.</response>
         /// <returns>The first user.</returns>
         [HttpGet("User")]
-        public StartupUserDto GetFirstUser()
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<StartupUserDto> GetFirstUser()
         {
             var user = _userManager.Users.First();
-            return new StartupUserDto
-            {
-                Name = user.Name,
-                Password = user.Password
-            };
+            return new StartupUserDto { Name = user.Name, Password = user.Password };
         }
 
         /// <summary>
-        /// Endpoint for updating the user name and password.
+        /// Sets the user name and password.
         /// </summary>
         /// <param name="startupUserDto">The DTO containing username and password.</param>
-        /// <returns>The async task.</returns>
+        /// <response code="200">Updated user name and password.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous update operation.
+        /// The task result contains an <see cref="OkResult"/> indicating success.
+        /// </returns>
         [HttpPost("User")]
-        public async Task UpdateUser([FromForm] StartupUserDto startupUserDto)
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> UpdateUser([FromForm] StartupUserDto startupUserDto)
         {
             var user = _userManager.Users.First();
 
@@ -121,6 +139,8 @@ namespace Jellyfin.Api.Controllers
             {
                 await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
             }
+
+            return Ok();
         }
     }
 }

+ 18 - 0
Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Api.Models.ConfigurationDtos
+{
+    /// <summary>
+    /// Media Encoder Path Dto.
+    /// </summary>
+    public class MediaEncoderPathDto
+    {
+        /// <summary>
+        /// Gets or sets media encoder path.
+        /// </summary>
+        public string Path { get; set; }
+
+        /// <summary>
+        /// Gets or sets media encoder path type.
+        /// </summary>
+        public string PathType { get; set; }
+    }
+}

+ 23 - 9
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -10,6 +10,7 @@ using Jellyfin.Api.Auth.RequiresElevationPolicy;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Controllers;
 using Jellyfin.Server.Formatters;
+using MediaBrowser.Common.Json;
 using MediaBrowser.Model.Entities;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authorization;
@@ -83,8 +84,20 @@ namespace Jellyfin.Server.Extensions
                 .AddApplicationPart(typeof(StartupController).Assembly)
                 .AddJsonOptions(options =>
                 {
-                    // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON.
-                    options.JsonSerializerOptions.PropertyNamingPolicy = null;
+                    // Update all properties that are set in JsonDefaults
+                    var jsonOptions = JsonDefaults.PascalCase;
+
+                    // From JsonDefaults
+                    options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling;
+                    options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented;
+                    options.JsonSerializerOptions.Converters.Clear();
+                    foreach (var converter in jsonOptions.Converters)
+                    {
+                        options.JsonSerializerOptions.Converters.Add(converter);
+                    }
+
+                    // From JsonDefaults.PascalCase
+                    options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy;
                 })
                 .AddControllersAsServices();
         }
@@ -98,7 +111,7 @@ namespace Jellyfin.Server.Extensions
         {
             return serviceCollection.AddSwaggerGen(c =>
             {
-                c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API" });
+                c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" });
 
                 // Add all xml doc files to swagger generator.
                 var xmlFiles = Directory.GetFiles(
@@ -119,16 +132,17 @@ namespace Jellyfin.Server.Extensions
                 c.CustomOperationIds(description =>
                     description.TryGetMethodInfo(out MethodInfo methodInfo) ? methodInfo.Name : null);
 
-                // Add types not supported by System.Text.Json
-                // TODO: Remove this once these types are supported by System.Text.Json and Swashbuckle
-                // See: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1667
-                c.MapSwaggerGenTypes();
+                // TODO - remove when all types are supported in System.Text.Json
+                c.AddSwaggerTypeMappings();
             });
         }
 
-        private static void MapSwaggerGenTypes(this SwaggerGenOptions options)
+        private static void AddSwaggerTypeMappings(this SwaggerGenOptions options)
         {
-            // BaseItemDto.ImageTags
+            /*
+             * TODO remove when System.Text.Json supports non-string keys.
+             * Used in Jellyfin.Api.Controller.GetChannels.
+             */
             options.MapType<Dictionary<ImageType, string>>(() =>
                 new OpenApiSchema
                 {

+ 2 - 2
Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs

@@ -1,4 +1,4 @@
-using Jellyfin.Server.Models;
+using MediaBrowser.Common.Json;
 using Microsoft.AspNetCore.Mvc.Formatters;
 using Microsoft.Net.Http.Headers;
 
@@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters
         /// <summary>
         /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class.
         /// </summary>
-        public CamelCaseJsonProfileFormatter() : base(JsonOptions.CamelCase)
+        public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCase)
         {
             SupportedMediaTypes.Clear();
             SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\""));

+ 2 - 2
Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs

@@ -1,4 +1,4 @@
-using Jellyfin.Server.Models;
+using MediaBrowser.Common.Json;
 using Microsoft.AspNetCore.Mvc.Formatters;
 using Microsoft.Net.Http.Headers;
 
@@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters
         /// <summary>
         /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class.
         /// </summary>
-        public PascalCaseJsonProfileFormatter() : base(JsonOptions.PascalCase)
+        public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCase)
         {
             SupportedMediaTypes.Clear();
             // Add application/json for default formatter

+ 0 - 41
Jellyfin.Server/Models/JsonOptions.cs

@@ -1,41 +0,0 @@
-using System.Text.Json;
-
-namespace Jellyfin.Server.Models
-{
-    /// <summary>
-    /// Json Options.
-    /// </summary>
-    public static class JsonOptions
-    {
-        /// <summary>
-        /// Gets CamelCase json options.
-        /// </summary>
-        public static JsonSerializerOptions CamelCase
-        {
-            get
-            {
-                var options = DefaultJsonOptions;
-                options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
-                return options;
-            }
-        }
-
-        /// <summary>
-        /// Gets PascalCase json options.
-        /// </summary>
-        public static JsonSerializerOptions PascalCase
-        {
-            get
-            {
-                var options = DefaultJsonOptions;
-                options.PropertyNamingPolicy = null;
-                return options;
-            }
-        }
-
-        /// <summary>
-        /// Gets base Json Serializer Options.
-        /// </summary>
-        private static JsonSerializerOptions DefaultJsonOptions => new JsonSerializerOptions();
-    }
-}

+ 0 - 146
MediaBrowser.Api/ConfigurationService.cs

@@ -1,146 +0,0 @@
-using System.IO;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Serialization;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api
-{
-    /// <summary>
-    /// Class GetConfiguration
-    /// </summary>
-    [Route("/System/Configuration", "GET", Summary = "Gets application configuration")]
-    [Authenticated]
-    public class GetConfiguration : IReturn<ServerConfiguration>
-    {
-
-    }
-
-    [Route("/System/Configuration/{Key}", "GET", Summary = "Gets a named configuration")]
-    [Authenticated(AllowBeforeStartupWizard = true)]
-    public class GetNamedConfiguration
-    {
-        [ApiMember(Name = "Key", Description = "Key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Key { get; set; }
-    }
-
-    /// <summary>
-    /// Class UpdateConfiguration
-    /// </summary>
-    [Route("/System/Configuration", "POST", Summary = "Updates application configuration")]
-    [Authenticated(Roles = "Admin")]
-    public class UpdateConfiguration : ServerConfiguration, IReturnVoid
-    {
-    }
-
-    [Route("/System/Configuration/{Key}", "POST", Summary = "Updates named configuration")]
-    [Authenticated(Roles = "Admin")]
-    public class UpdateNamedConfiguration : IReturnVoid, IRequiresRequestStream
-    {
-        [ApiMember(Name = "Key", Description = "Key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Key { get; set; }
-
-        public Stream RequestStream { get; set; }
-    }
-
-    [Route("/System/Configuration/MetadataOptions/Default", "GET", Summary = "Gets a default MetadataOptions object")]
-    [Authenticated(Roles = "Admin")]
-    public class GetDefaultMetadataOptions : IReturn<MetadataOptions>
-    {
-
-    }
-
-    [Route("/System/MediaEncoder/Path", "POST", Summary = "Updates the path to the media encoder")]
-    [Authenticated(Roles = "Admin", AllowBeforeStartupWizard = true)]
-    public class UpdateMediaEncoderPath : IReturnVoid
-    {
-        [ApiMember(Name = "Path", Description = "Path", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Path { get; set; }
-        [ApiMember(Name = "PathType", Description = "PathType", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string PathType { get; set; }
-    }
-
-    public class ConfigurationService : BaseApiService
-    {
-        /// <summary>
-        /// The _json serializer
-        /// </summary>
-        private readonly IJsonSerializer _jsonSerializer;
-
-        /// <summary>
-        /// The _configuration manager
-        /// </summary>
-        private readonly IServerConfigurationManager _configurationManager;
-
-        private readonly IMediaEncoder _mediaEncoder;
-
-        public ConfigurationService(
-            ILogger<ConfigurationService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IJsonSerializer jsonSerializer,
-            IServerConfigurationManager configurationManager,
-            IMediaEncoder mediaEncoder)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _jsonSerializer = jsonSerializer;
-            _configurationManager = configurationManager;
-            _mediaEncoder = mediaEncoder;
-        }
-
-        public void Post(UpdateMediaEncoderPath request)
-        {
-            _mediaEncoder.UpdateEncoderPath(request.Path, request.PathType);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetConfiguration request)
-        {
-            return ToOptimizedResult(_configurationManager.Configuration);
-        }
-
-        public object Get(GetNamedConfiguration request)
-        {
-            var result = _configurationManager.GetConfiguration(request.Key);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Posts the specified configuraiton.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(UpdateConfiguration request)
-        {
-            // Silly, but we need to serialize and deserialize or the XmlSerializer will write the xml with an element name of UpdateConfiguration
-            var json = _jsonSerializer.SerializeToString(request);
-
-            var config = _jsonSerializer.DeserializeFromString<ServerConfiguration>(json);
-
-            _configurationManager.ReplaceConfiguration(config);
-        }
-
-        public async Task Post(UpdateNamedConfiguration request)
-        {
-            var key = GetPathValue(2).ToString();
-
-            var configurationType = _configurationManager.GetConfigurationType(key);
-            var configuration = await _jsonSerializer.DeserializeFromStreamAsync(request.RequestStream, configurationType).ConfigureAwait(false);
-
-            _configurationManager.SaveConfiguration(key, configuration);
-        }
-
-        public object Get(GetDefaultMetadataOptions request)
-        {
-            return ToOptimizedResult(new MetadataOptions());
-        }
-    }
-}

+ 78 - 0
MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs

@@ -0,0 +1,78 @@
+#nullable enable
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Converter for Dictionaries without string key.
+    /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys.
+    /// </summary>
+    /// <typeparam name="TKey">Type of key.</typeparam>
+    /// <typeparam name="TValue">Type of value.</typeparam>
+    internal sealed class JsonNonStringKeyDictionaryConverter<TKey, TValue> : JsonConverter<IDictionary<TKey, TValue>>
+    {
+        /// <summary>
+        /// Read JSON.
+        /// </summary>
+        /// <param name="reader">The Utf8JsonReader.</param>
+        /// <param name="typeToConvert">The type to convert.</param>
+        /// <param name="options">The json serializer options.</param>
+        /// <returns>Typed dictionary.</returns>
+        /// <exception cref="NotSupportedException"></exception>
+        public override IDictionary<TKey, TValue> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        {
+            var convertedType = typeof(Dictionary<,>).MakeGenericType(typeof(string), typeToConvert.GenericTypeArguments[1]);
+            var value = JsonSerializer.Deserialize(ref reader, convertedType, options);
+            var instance = (Dictionary<TKey, TValue>)Activator.CreateInstance(
+                typeToConvert,
+                BindingFlags.Instance | BindingFlags.Public,
+                null,
+                null,
+                CultureInfo.CurrentCulture);
+            var enumerator = (IEnumerator)convertedType.GetMethod("GetEnumerator")!.Invoke(value, null);
+            var parse = typeof(TKey).GetMethod(
+                "Parse", 
+                0, 
+                BindingFlags.Public | BindingFlags.Static, 
+                null, 
+                CallingConventions.Any, 
+                new[] { typeof(string) }, 
+                null);
+            if (parse == null)
+            {
+                throw new NotSupportedException($"{typeof(TKey)} as TKey in IDictionary<TKey, TValue> is not supported.");
+            }
+            
+            while (enumerator.MoveNext())
+            {
+                var element = (KeyValuePair<string?, TValue>)enumerator.Current;
+                instance.Add((TKey)parse.Invoke(null, new[] { (object?) element.Key }), element.Value);
+            }
+            
+            return instance;
+        }
+
+        /// <summary>
+        /// Write dictionary as Json.
+        /// </summary>
+        /// <param name="writer">The Utf8JsonWriter.</param>
+        /// <param name="value">The dictionary value.</param>
+        /// <param name="options">The Json serializer options.</param>
+        public override void Write(Utf8JsonWriter writer, IDictionary<TKey, TValue> value, JsonSerializerOptions options)
+        {
+            var convertedDictionary = new Dictionary<string?, TValue>(value.Count);
+            foreach (var (k, v) in value)
+            {
+                convertedDictionary[k?.ToString()] = v;
+            }
+            JsonSerializer.Serialize(writer, convertedDictionary, options);
+        }
+    }
+}

+ 60 - 0
MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs

@@ -0,0 +1,60 @@
+#nullable enable
+
+using System;
+using System.Collections;
+using System.Globalization;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// https://github.com/dotnet/runtime/issues/30524#issuecomment-524619972.
+    /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys.
+    /// </summary>
+    internal sealed class JsonNonStringKeyDictionaryConverterFactory : JsonConverterFactory
+    {
+        /// <summary>
+        /// Only convert objects that implement IDictionary and do not have string keys.
+        /// </summary>
+        /// <param name="typeToConvert">Type convert.</param>
+        /// <returns>Conversion ability.</returns>
+        public override bool CanConvert(Type typeToConvert)
+        {
+            
+            if (!typeToConvert.IsGenericType)
+            {
+                return false;
+            }
+            
+            // Let built in converter handle string keys
+            if (typeToConvert.GenericTypeArguments[0] == typeof(string))
+            {
+                return false;
+            }
+            
+            // Only support objects that implement IDictionary
+            return typeToConvert.GetInterface(nameof(IDictionary)) != null;
+        }
+
+        /// <summary>
+        /// Create converter for generic dictionary type.
+        /// </summary>
+        /// <param name="typeToConvert">Type to convert.</param>
+        /// <param name="options">Json serializer options.</param>
+        /// <returns>JsonConverter for given type.</returns>
+        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+        {
+            var converterType = typeof(JsonNonStringKeyDictionaryConverter<,>)
+                .MakeGenericType(typeToConvert.GenericTypeArguments[0], typeToConvert.GenericTypeArguments[1]);
+            var converter = (JsonConverter)Activator.CreateInstance(
+                converterType,
+                BindingFlags.Instance | BindingFlags.Public,
+                null,
+                null,
+                CultureInfo.CurrentCulture);
+            return converter;
+        }
+    }
+}

+ 34 - 1
MediaBrowser.Common/Json/JsonDefaults.cs

@@ -12,10 +12,16 @@ namespace MediaBrowser.Common.Json
         /// <summary>
         /// Gets the default <see cref="JsonSerializerOptions" /> options.
         /// </summary>
+        /// <remarks>
+        /// When changing these options, update
+        ///     Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+        ///         -> AddJellyfinApi
+        ///             -> AddJsonOptions
+        /// </remarks>
         /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns>
         public static JsonSerializerOptions GetOptions()
         {
-            var options = new JsonSerializerOptions()
+            var options = new JsonSerializerOptions
             {
                 ReadCommentHandling = JsonCommentHandling.Disallow,
                 WriteIndented = false
@@ -23,8 +29,35 @@ namespace MediaBrowser.Common.Json
 
             options.Converters.Add(new JsonGuidConverter());
             options.Converters.Add(new JsonStringEnumConverter());
+            options.Converters.Add(new JsonNonStringKeyDictionaryConverterFactory());
 
             return options;
         }
+        
+        /// <summary>
+        /// Gets CamelCase json options.
+        /// </summary>
+        public static JsonSerializerOptions CamelCase
+        {
+            get
+            {
+                var options = GetOptions();
+                options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+                return options;
+            }
+        }
+
+        /// <summary>
+        /// Gets PascalCase json options.
+        /// </summary>
+        public static JsonSerializerOptions PascalCase
+        {
+            get
+            {
+                var options = GetOptions();
+                options.PropertyNamingPolicy = null;
+                return options;
+            }
+        }
     }
 }