Sfoglia il codice sorgente

Merge remote-tracking branch 'upstream/api-migration' into api-cors

crobibero 5 anni fa
parent
commit
6be862545a

+ 2 - 12
Jellyfin.Api/Controllers/ConfigurationController.cs

@@ -1,12 +1,12 @@
 #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 MediaBrowser.Model.Serialization;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc;
@@ -23,22 +23,18 @@ namespace Jellyfin.Api.Controllers
     {
         private readonly IServerConfigurationManager _configurationManager;
         private readonly IMediaEncoder _mediaEncoder;
-        private readonly IJsonSerializer _jsonSerializer;
 
         /// <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>
-        /// <param name="jsonSerializer">Instance of the <see cref="IJsonSerializer"/> interface.</param>
         public ConfigurationController(
             IServerConfigurationManager configurationManager,
-            IMediaEncoder mediaEncoder,
-            IJsonSerializer jsonSerializer)
+            IMediaEncoder mediaEncoder)
         {
             _configurationManager = configurationManager;
             _mediaEncoder = mediaEncoder;
-            _jsonSerializer = jsonSerializer;
         }
 
         /// <summary>
@@ -93,13 +89,7 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key)
         {
             var configurationType = _configurationManager.GetConfigurationType(key);
-            /*
-            // TODO switch to System.Text.Json when https://github.com/dotnet/runtime/issues/30255 is fixed.
             var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType);
-            */
-
-            var configuration = await _jsonSerializer.DeserializeFromStreamAsync(Request.Body, configurationType)
-                .ConfigureAwait(false);
             _configurationManager.SaveConfiguration(key, configuration);
             return Ok();
         }

+ 156 - 0
Jellyfin.Api/Controllers/DevicesController.cs

@@ -0,0 +1,156 @@
+#nullable enable
+
+using System;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Devices Controller.
+    /// </summary>
+    [Authorize]
+    public class DevicesController : BaseJellyfinApiController
+    {
+        private readonly IDeviceManager _deviceManager;
+        private readonly IAuthenticationRepository _authenticationRepository;
+        private readonly ISessionManager _sessionManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DevicesController"/> class.
+        /// </summary>
+        /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="authenticationRepository">Instance of <see cref="IAuthenticationRepository"/> interface.</param>
+        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+        public DevicesController(
+            IDeviceManager deviceManager,
+            IAuthenticationRepository authenticationRepository,
+            ISessionManager sessionManager)
+        {
+            _deviceManager = deviceManager;
+            _authenticationRepository = authenticationRepository;
+            _sessionManager = sessionManager;
+        }
+
+        /// <summary>
+        /// Get Devices.
+        /// </summary>
+        /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param>
+        /// <param name="userId">Gets or sets the user identifier.</param>
+        /// <response code="200">Devices retrieved.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
+        [HttpGet]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
+        {
+            var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty };
+            return _deviceManager.GetDevices(deviceQuery);
+        }
+
+        /// <summary>
+        /// Get info for a device.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <response code="200">Device info retrieved.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [HttpGet("Info")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string id)
+        {
+            var deviceInfo = _deviceManager.GetDevice(id);
+            if (deviceInfo == null)
+            {
+                return NotFound();
+            }
+
+            return deviceInfo;
+        }
+
+        /// <summary>
+        /// Get options for a device.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <response code="200">Device options retrieved.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [HttpGet("Options")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string id)
+        {
+            var deviceInfo = _deviceManager.GetDeviceOptions(id);
+            if (deviceInfo == null)
+            {
+                return NotFound();
+            }
+
+            return deviceInfo;
+        }
+
+        /// <summary>
+        /// Update device options.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <param name="deviceOptions">Device Options.</param>
+        /// <response code="200">Device options updated.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [HttpPost("Options")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult UpdateDeviceOptions(
+            [FromQuery, BindRequired] string id,
+            [FromBody, BindRequired] DeviceOptions deviceOptions)
+        {
+            var existingDeviceOptions = _deviceManager.GetDeviceOptions(id);
+            if (existingDeviceOptions == null)
+            {
+                return NotFound();
+            }
+
+            _deviceManager.UpdateDeviceOptions(id, deviceOptions);
+            return Ok();
+        }
+
+        /// <summary>
+        /// Deletes a device.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <response code="200">Device deleted.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [HttpDelete]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult DeleteDevice([FromQuery, BindRequired] string id)
+        {
+            var existingDevice = _deviceManager.GetDevice(id);
+            if (existingDevice == null)
+            {
+                return NotFound();
+            }
+
+            var sessions = _authenticationRepository.Get(new AuthenticationInfoQuery { DeviceId = id }).Items;
+
+            foreach (var session in sessions)
+            {
+                _sessionManager.Logout(session);
+            }
+
+            return Ok();
+        }
+    }
+}

+ 40 - 4
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -3,7 +3,6 @@ using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using System.Reflection;
-using System.Text.Json.Serialization;
 using Jellyfin.Api;
 using Jellyfin.Api.Auth;
 using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy;
@@ -12,6 +11,8 @@ using Jellyfin.Api.Constants;
 using Jellyfin.Api.Controllers;
 using Jellyfin.Server.Formatters;
 using Jellyfin.Server.Models;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Model.Entities;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.Extensions.DependencyInjection;
@@ -89,8 +90,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();
         }
@@ -104,7 +117,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(
@@ -124,7 +137,30 @@ namespace Jellyfin.Server.Extensions
                 // Use method name as operationId
                 c.CustomOperationIds(description =>
                     description.TryGetMethodInfo(out MethodInfo methodInfo) ? methodInfo.Name : null);
+
+                // TODO - remove when all types are supported in System.Text.Json
+                c.AddSwaggerTypeMappings();
             });
         }
+
+        private static void AddSwaggerTypeMappings(this SwaggerGenOptions options)
+        {
+            /*
+             * TODO remove when System.Text.Json supports non-string keys.
+             * Used in Jellyfin.Api.Controller.GetChannels.
+             */
+            options.MapType<Dictionary<ImageType, string>>(() =>
+                new OpenApiSchema
+                {
+                    Type = "object",
+                    Properties = typeof(ImageType).GetEnumNames().ToDictionary(
+                        name => name,
+                        name => new OpenApiSchema
+                        {
+                            Type = "string",
+                            Format = "string"
+                        })
+                });
+        }
     }
 }

+ 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

+ 12 - 2
Jellyfin.Server/Middleware/ExceptionMiddleware.cs

@@ -7,7 +7,9 @@ using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Net;
+using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 
 namespace Jellyfin.Server.Middleware
@@ -20,6 +22,7 @@ namespace Jellyfin.Server.Middleware
         private readonly RequestDelegate _next;
         private readonly ILogger<ExceptionMiddleware> _logger;
         private readonly IServerConfigurationManager _configuration;
+        private readonly IWebHostEnvironment _hostEnvironment;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class.
@@ -27,14 +30,17 @@ namespace Jellyfin.Server.Middleware
         /// <param name="next">Next request delegate.</param>
         /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param>
         /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param>
         public ExceptionMiddleware(
             RequestDelegate next,
             ILogger<ExceptionMiddleware> logger,
-            IServerConfigurationManager serverConfigurationManager)
+            IServerConfigurationManager serverConfigurationManager,
+            IWebHostEnvironment hostEnvironment)
         {
             _next = next;
             _logger = logger;
             _configuration = serverConfigurationManager;
+            _hostEnvironment = hostEnvironment;
         }
 
         /// <summary>
@@ -85,7 +91,11 @@ namespace Jellyfin.Server.Middleware
 
                 context.Response.StatusCode = GetStatusCode(ex);
                 context.Response.ContentType = MediaTypeNames.Text.Plain;
-                var errorContent = NormalizeExceptionMessage(ex.Message);
+
+                // Don't send exception unless the server is in a Development environment
+                var errorContent = _hostEnvironment.IsDevelopment()
+                        ? NormalizeExceptionMessage(ex.Message)
+                        : "Error processing request.";
                 await context.Response.WriteAsync(errorContent).ConfigureAwait(false);
             }
         }

+ 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 - 168
MediaBrowser.Api/Devices/DeviceService.cs

@@ -1,168 +0,0 @@
-using System.IO;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Devices;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Security;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.Devices;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.Devices
-{
-    [Route("/Devices", "GET", Summary = "Gets all devices")]
-    [Authenticated(Roles = "Admin")]
-    public class GetDevices : DeviceQuery, IReturn<QueryResult<DeviceInfo>>
-    {
-    }
-
-    [Route("/Devices/Info", "GET", Summary = "Gets info for a device")]
-    [Authenticated(Roles = "Admin")]
-    public class GetDeviceInfo : IReturn<DeviceInfo>
-    {
-        [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Devices/Options", "GET", Summary = "Gets options for a device")]
-    [Authenticated(Roles = "Admin")]
-    public class GetDeviceOptions : IReturn<DeviceOptions>
-    {
-        [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Devices", "DELETE", Summary = "Deletes a device")]
-    public class DeleteDevice
-    {
-        [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Devices/CameraUploads", "GET", Summary = "Gets camera upload history for a device")]
-    [Authenticated]
-    public class GetCameraUploads : IReturn<ContentUploadHistory>
-    {
-        [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string DeviceId { get; set; }
-    }
-
-    [Route("/Devices/CameraUploads", "POST", Summary = "Uploads content")]
-    [Authenticated]
-    public class PostCameraUpload : IRequiresRequestStream, IReturnVoid
-    {
-        [ApiMember(Name = "DeviceId", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string DeviceId { get; set; }
-
-        [ApiMember(Name = "Album", Description = "Album", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Album { get; set; }
-
-        [ApiMember(Name = "Name", Description = "Name", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Name { get; set; }
-
-        [ApiMember(Name = "Id", Description = "Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Id { get; set; }
-
-        public Stream RequestStream { get; set; }
-    }
-
-    [Route("/Devices/Options", "POST", Summary = "Updates device options")]
-    [Authenticated(Roles = "Admin")]
-    public class PostDeviceOptions : DeviceOptions, IReturnVoid
-    {
-        [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    public class DeviceService : BaseApiService
-    {
-        private readonly IDeviceManager _deviceManager;
-        private readonly IAuthenticationRepository _authRepo;
-        private readonly ISessionManager _sessionManager;
-
-        public DeviceService(
-            ILogger<DeviceService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IDeviceManager deviceManager,
-            IAuthenticationRepository authRepo,
-            ISessionManager sessionManager)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _deviceManager = deviceManager;
-            _authRepo = authRepo;
-            _sessionManager = sessionManager;
-        }
-
-        public void Post(PostDeviceOptions request)
-        {
-            _deviceManager.UpdateDeviceOptions(request.Id, request);
-        }
-
-        public object Get(GetDevices request)
-        {
-            return ToOptimizedResult(_deviceManager.GetDevices(request));
-        }
-
-        public object Get(GetDeviceInfo request)
-        {
-            return _deviceManager.GetDevice(request.Id);
-        }
-
-        public object Get(GetDeviceOptions request)
-        {
-            return _deviceManager.GetDeviceOptions(request.Id);
-        }
-
-        public object Get(GetCameraUploads request)
-        {
-            return ToOptimizedResult(_deviceManager.GetCameraUploadHistory(request.DeviceId));
-        }
-
-        public void Delete(DeleteDevice request)
-        {
-            var sessions = _authRepo.Get(new AuthenticationInfoQuery
-            {
-                DeviceId = request.Id
-
-            }).Items;
-
-            foreach (var session in sessions)
-            {
-                _sessionManager.Logout(session);
-            }
-        }
-
-        public Task Post(PostCameraUpload request)
-        {
-            var deviceId = Request.QueryString["DeviceId"];
-            var album = Request.QueryString["Album"];
-            var id = Request.QueryString["Id"];
-            var name = Request.QueryString["Name"];
-            var req = Request.Response.HttpContext.Request;
-
-            if (req.HasFormContentType)
-            {
-                var file = req.Form.Files.Count == 0 ? null : req.Form.Files[0];
-
-                return _deviceManager.AcceptCameraUpload(deviceId, file.OpenReadStream(), new LocalFileInfo
-                {
-                    MimeType = file.ContentType,
-                    Album = album,
-                    Name = name,
-                    Id = id
-                });
-            }
-
-            return _deviceManager.AcceptCameraUpload(deviceId, request.RequestStream, new LocalFileInfo
-            {
-                MimeType = Request.ContentType,
-                Album = album,
-                Name = name,
-                Id = id
-            });
-        }
-    }
-}

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